Coverage for src/lexigram/auth/authn/apikeys.py: 96%
53 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
1"""API Key Management for Lexigram Auth."""
3from __future__ import annotations
5from datetime import datetime, timedelta
6import secrets
7import string
9from lexigram.auth.authn.security import PasswordHasher
10from lexigram.auth.models.apikey import APIKey
11from lexigram.contracts.auth import APIKeyRepositoryProtocol
12from lexigram.di.decorators import inject
13from lexigram.logging import get_logger
15logger = get_logger(__name__)
18@inject
19class APIKeyManager:
20 """Manages API keys for service-to-service authentication.
22 API keys follow the format: ``{prefix}_{random}`` — e.g. ``sk_live_abc123``.
23 All persistence is delegated to an injected ``APIKeyRepositoryProtocol``; this
24 class contains only key-management business logic.
25 """
27 KEY_LENGTH = 32
28 DISPLAY_PREFIX_LENGTH = 8
30 def __init__(self, repo: APIKeyRepositoryProtocol) -> None:
31 """Initialise with the API key repository.
33 Args:
34 repo: Persistence abstraction for API key records. Injected by
35 the DI container; typically backed by ``APIKeySqlRepository``.
36 """
37 self._repo = repo
39 def generate_raw_key(self, prefix: str = "sk_live") -> str:
40 """Generate a cryptographically secure random API key.
42 Args:
43 prefix: Human-readable key family prefix (e.g. ``sk_live``).
45 Returns:
46 Full raw key string in ``{prefix}_{random}`` format.
47 """
48 alphabet = string.ascii_letters + string.digits
49 key = "".join(secrets.choice(alphabet) for _ in range(self.KEY_LENGTH))
50 return f"{prefix}_{key}"
52 async def create_key(
53 self,
54 user_id: str,
55 name: str,
56 scopes: list[str] | None = None,
57 expires_days: int | None = 365,
58 prefix: str = "sk_live",
59 ) -> tuple[str, APIKey]:
60 """Create a new API key and persist its hash via the repository.
62 Args:
63 user_id: Owner of the API key.
64 name: Human-readable label for the key.
65 scopes: Optional list of permission scopes.
66 expires_days: Days until expiry (``None`` for non-expiring keys).
67 prefix: Key family prefix written into the display prefix field.
69 Returns:
70 ``(raw_key, api_key_object)`` — the raw key is only returned once.
71 """
72 raw_key = self.generate_raw_key(prefix)
73 key_hash = await PasswordHasher().hash(raw_key)
74 display_prefix = raw_key[: self.DISPLAY_PREFIX_LENGTH]
76 expires_at: datetime | None = None
77 if expires_days:
78 expires_at = datetime.now() + timedelta(days=expires_days)
80 payload = {
81 "name": name,
82 "key_hash": key_hash,
83 "prefix": display_prefix,
84 "user_id": user_id,
85 "scopes": scopes or [],
86 "expires_at": expires_at,
87 }
89 key_id = await self._repo.insert(payload)
91 api_key = APIKey(
92 key_id=key_id,
93 name=name,
94 key_hash=key_hash,
95 prefix=display_prefix,
96 user_id=user_id,
97 scopes=scopes or [],
98 expires_at=expires_at,
99 created_at=datetime.now(),
100 updated_at=datetime.now(),
101 )
103 logger.info("Created API key for user %s: %s", user_id, name)
104 return raw_key, api_key
106 async def validate_key(
107 self,
108 raw_key: str,
109 ip_address: str | None = None,
110 ) -> APIKey | None:
111 """Validate a raw API key and refresh its last-used metadata.
113 Uses prefix pre-filtering for O(small) constant-time hash comparisons
114 even with a large keyspace.
116 Args:
117 raw_key: Full plain-text API key submitted by the caller.
118 ip_address: Originating IP for audit metadata (optional).
120 Returns:
121 Hydrated ``APIKey`` on success, ``None`` on invalid/expired key.
122 """
123 display_prefix = raw_key[: self.DISPLAY_PREFIX_LENGTH]
124 rows = await self._repo.find_by_prefix(display_prefix)
126 for row in rows:
127 if await PasswordHasher().verify(raw_key, row["key_hash"]):
128 expires_at = row.get("expires_at")
129 if expires_at and expires_at < datetime.now():
130 logger.warning("Expired API key attempt: %s", row["id"])
131 continue
133 await self._repo.update_last_used(row["id"], ip_address)
135 return APIKey(
136 key_id=str(row["id"]),
137 name=row["name"],
138 key_hash=row["key_hash"],
139 prefix=row["prefix"],
140 user_id=str(row["user_id"]),
141 scopes=row["scopes"],
142 expires_at=expires_at,
143 last_used_at=datetime.now(),
144 last_used_ip=ip_address,
145 created_at=row["created_at"],
146 updated_at=row["updated_at"],
147 )
149 return None
151 async def revoke_key(self, key_id: str) -> bool:
152 """Revoke an API key permanently.
154 Args:
155 key_id: Identifier of the key to revoke.
157 Returns:
158 ``True`` when a live key was revoked; ``False`` when the id is
159 unknown or already revoked.
160 """
161 revoked = await self._repo.revoke(key_id)
162 if revoked:
163 logger.info("Revoked API key: %s", key_id)
164 return revoked
166 async def list_keys(self, user_id: str) -> list[APIKey]:
167 """List all active, non-revoked API keys for a user.
169 Args:
170 user_id: Owner identifier.
172 Returns:
173 List of hydrated ``APIKey`` objects.
174 """
175 rows = await self._repo.find_by_user(user_id)
176 return [
177 APIKey(
178 key_id=str(row["id"]),
179 name=row["name"],
180 key_hash=row["key_hash"],
181 prefix=row["prefix"],
182 user_id=str(row["user_id"]),
183 scopes=row["scopes"],
184 expires_at=row.get("expires_at"),
185 last_used_at=row.get("last_used_at"),
186 last_used_ip=row.get("last_used_ip"),
187 created_at=row["created_at"],
188 updated_at=row["updated_at"],
189 )
190 for row in rows
191 ]
194__all__ = ["APIKeyManager"]