"""API-client token lifecycle — mint, authenticate, revoke. Token format: ``helix_api_`` + 32 base32 chars (160 bits), HMAC-SHA-256 at rest (see ``app.core.service_token``). The distinct prefix keeps a leaked credential classifiable in logs and lets every other auth path reject it: the PAT/MCP path takes ``helix_pat_``, the directory-pull path takes ``helix_svc_``, and this path takes ``helix_api_`` only. """ from __future__ import annotations import logging from datetime import UTC, datetime, timedelta from uuid import UUID from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.api_clients.models import ( CLIENT_STATUS_ACTIVE, ApiClient, ApiClientToken, ) from app.core.exceptions import ConflictError, NotFoundError, ValidationError from app.core.service_token import as_aware_utc, generate_token, hash_token logger = logging.getLogger(__name__) API_TOKEN_PREFIX = "helix_api_" ALLOWED_EXPIRY_DAYS: frozenset[int] = frozenset({30, 90, 180, 365}) MAX_ACTIVE_TOKENS_PER_CLIENT = 3 def token_is_active(row: ApiClientToken) -> bool: if row.revoked_at is not None: return False expires = as_aware_utc(row.expires_at) return expires is None or expires > datetime.now(UTC) class ApiClientTokenService: def __init__(self, db: AsyncSession) -> None: self.db = db async def mint( self, *, client: ApiClient, name: str, expires_in_days: int | None, created_by: UUID | None, ) -> tuple[ApiClientToken, str]: """Mint a token for ``client`` and return ``(row, plaintext)``. Plaintext is shown exactly once. Caller commits.""" clean_name = (name or "").strip() if not clean_name: raise ValidationError("Token name is required.") if len(clean_name) > 120: raise ValidationError("Token name must be 120 characters or fewer.") if expires_in_days is not None and expires_in_days not in ALLOWED_EXPIRY_DAYS: raise ValidationError( f"expires_in_days must be one of {sorted(ALLOWED_EXPIRY_DAYS)} or null." ) active = [t for t in client.tokens if token_is_active(t)] if len(active) >= MAX_ACTIVE_TOKENS_PER_CLIENT: raise ConflictError( f"Maximum of {MAX_ACTIVE_TOKENS_PER_CLIENT} active tokens per " "client. Revoke an old token before minting a new one." ) plaintext, prefix = generate_token(API_TOKEN_PREFIX) token_hash = hash_token(plaintext) existing = await self.db.execute( select(ApiClientToken.id).where(ApiClientToken.token_hash == token_hash) ) if existing.scalar_one_or_none() is not None: raise ConflictError("Token generation collision; please retry.") expires_at: datetime | None = None if expires_in_days is not None: expires_at = datetime.now(UTC) + timedelta(days=expires_in_days) row = ApiClientToken( client_id=client.id, name=clean_name, token_hash=token_hash, token_prefix=prefix, expires_at=expires_at, created_by=created_by, ) self.db.add(row) await self.db.flush() return row, plaintext async def revoke( self, *, client_id: UUID, token_id: UUID, actor_user_id: UUID, ) -> ApiClientToken: """Idempotent revoke — a duplicate click reads as success.""" row = await self.db.get(ApiClientToken, token_id) if row is None or row.client_id != client_id: raise NotFoundError("ApiClientToken", str(token_id)) if row.revoked_at is not None: return row row.revoked_at = datetime.now(UTC) row.revoked_by_user_id = actor_user_id await self.db.flush() return row async def authenticate( self, plaintext: str, ) -> tuple[ApiClient, ApiClientToken] | None: """Resolve a plaintext token to ``(client, token)``. Returns ``None`` for every failure mode (unknown / revoked / expired token, disabled / deleted client) so the caller answers one uniform 401. A successfully presented legacy SHA-256 fingerprint is upgraded to HMAC-SHA-256 in the caller's transaction; all other auth bookkeeping remains in the dependency's independent session.""" if not plaintext or not plaintext.startswith(API_TOKEN_PREFIX): return None token_hash = hash_token(plaintext) result = await self.db.execute( select(ApiClientToken) .where(ApiClientToken.token_hash == token_hash) .options( selectinload(ApiClientToken.client).selectinload(ApiClient.tokens) ) ) row = result.scalar_one_or_none() if row is None: # Compatibility for tokens minted before keyed fingerprints. # Compute the legacy SHA-256 inside MySQL so Python never treats a # fast password-style hash as a current storage primitive. Once a # real token proves possession, replace that row with the keyed # fingerprint; no plaintext is persisted or logged. result = await self.db.execute( select(ApiClientToken) .where(ApiClientToken.token_hash == func.sha2(plaintext, 256)) .options( selectinload(ApiClientToken.client).selectinload( ApiClient.tokens, ) ) ) row = result.scalar_one_or_none() if row is not None: row.token_hash = token_hash await self.db.flush() if row is None or not token_is_active(row): return None client = row.client if ( client is None or client.deleted_at is not None or client.status != CLIENT_STATUS_ACTIVE ): return None return client, row __all__ = [ "ALLOWED_EXPIRY_DAYS", "API_TOKEN_PREFIX", "MAX_ACTIVE_TOKENS_PER_CLIENT", "ApiClientTokenService", "token_is_active", ]