"""HTTP endpoints for personal access token management. Mounted under ``/api/v1/auth/me/tokens`` (i.e. nested under the auth router prefix, but registered as its own ``APIRouter`` to keep file sizes manageable). All four endpoints require an authenticated user; the create / revoke audit trail records both the action and the client metadata so the user can reconstruct what happened to their own tokens from the audit log alone. """ from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.auth.tokens.schemas import ( TokenCreateRequest, TokenCreateResponse, TokenView, ) from app.auth.tokens.service import ( PersonalAccessTokenService, is_active, ) from app.core.database import get_db from app.core.dependencies import ( check_sensitive_endpoint_rate_limit, get_current_user, get_real_ip, require_browser_session, ) from app.core.idempotent_route import idempotent from app.core.response import success_response router = APIRouter(prefix="/api/v1/auth/me/tokens", tags=["auth", "personal-access-tokens"]) def _serialize(row) -> dict: return TokenView( id=row.id, name=row.name, token_prefix=row.token_prefix, created_at=row.created_at, expires_at=row.expires_at, revoked_at=row.revoked_at, last_used_at=row.last_used_at, last_used_ip=row.last_used_ip, last_used_user_agent=row.last_used_user_agent, is_active=is_active(row), ).model_dump(mode="json") @router.get("", summary="List my personal access tokens") async def list_my_tokens( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = PersonalAccessTokenService(db) rows = await service.list_for_user(current_user.id) return success_response( data=[_serialize(r) for r in rows], request=request, ) @router.post( "", summary="Create a new personal access token", response_model=None, dependencies=[Depends(check_sensitive_endpoint_rate_limit)], ) # Minting is NOT naturally idempotent: each call creates a new token row and # returns a NEW plaintext. Without a key, a weak-network retry (the norm on a # phone, and this surface is about to be reachable from the mobile shell) # leaves the user with several live tokens for one intended mint — extra # credentials they do not know exist and will never revoke. Scoped per # caller: the same person retrying is a retry, a different person with a # colliding key is a different operation. @idempotent( "auth.create_pat", key_params=("current_user.id",), # The response carries a LIVE credential. Caching it would park the # plaintext in `core_idempotency_records` for the full TTL, readable by # anything that can read the database — quietly retracting this # endpoint's own "the server stores only a hash" promise. The first # caller still gets the real value; a replay gets the metadata plus # `plaintext_withheld_on_replay`, which is enough for the UI to say # "this token exists but its secret is gone — revoke and mint again". redact_cached=("data.plaintext",), ) async def create_my_token( body: TokenCreateRequest, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), # A DEPENDENCY, not a call in the body: the idempotency decorator # returns a cached response BEFORE the handler body runs, so a # body-level gate is skipped on every replay. As a dependency it runs # on the replay too — which matters, because this gate is what stops a # PAT-authenticated caller from minting more PATs. _browser_only: None = Depends(require_browser_session), ): """Mint a new PAT and return the plaintext exactly once. The response body contains both the metadata (``token``) and the plaintext (``plaintext``). The frontend MUST display the plaintext immediately and discard it from memory once the user confirms they have copied it — the server retains only the SHA-256 hash. """ service = PersonalAccessTokenService(db) audit = AuditService(db) client_ip = get_real_ip(request) user_agent = request.headers.get("User-Agent") row, plaintext = await service.create( user_id=current_user.id, name=body.name, expires_in_days=body.expires_in_days, created_ip=client_ip, created_user_agent=user_agent, ) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="auth.pat.created", status="success", resource_type="personal_access_token", resource_id=str(row.id), detail={ "name": row.name, "token_prefix": row.token_prefix, "expires_at": row.expires_at.isoformat() if row.expires_at else None, }, ip_address=client_ip, user_agent=user_agent, ) # Capture all attributes BEFORE commit. SQLAlchemy's default # ``expire_on_commit=True`` invalidates ORM attributes after a # commit; touching them later triggers a lazy refresh that needs # a greenlet (sqlalchemy.exc.MissingGreenlet). The row was already # flushed and the values are correct in memory, so a snapshot is # equivalent to refreshing — and faster. snapshot = TokenView( id=row.id, name=row.name, token_prefix=row.token_prefix, created_at=row.created_at, expires_at=row.expires_at, revoked_at=row.revoked_at, last_used_at=row.last_used_at, last_used_ip=row.last_used_ip, last_used_user_agent=row.last_used_user_agent, is_active=is_active(row), ) await db.commit() response = TokenCreateResponse(token=snapshot, plaintext=plaintext) return success_response( data=response.model_dump(mode="json"), request=request, ) @router.delete("/{token_id}", summary="Revoke a personal access token") async def revoke_my_token( token_id: UUID, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): require_browser_session(request) service = PersonalAccessTokenService(db) audit = AuditService(db) row = await service.revoke( token_id=token_id, actor_user_id=current_user.id, is_admin=False, ) await audit.log( trace_id=request.state.trace_id, actor_type="user", actor_user_id=current_user.id, action="auth.pat.revoked", status="success", resource_type="personal_access_token", resource_id=str(row.id), detail={ "name": row.name, "token_prefix": row.token_prefix, }, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) # Snapshot before commit — see create_my_token for the rationale # (expire_on_commit + ORM attribute lazy-load needs a greenlet). snapshot = _serialize(row) await db.commit() return success_response(data=snapshot, request=request) __all__ = ["router"]