"""Local authentication provider — SQLite + bcrypt. The default :class:`AuthProvider` implementation. Authenticates users against the local ``users`` table in the auth SQLite database using the bcrypt cost=12 password hash (see :mod:`agentkit.server.auth.password`). This is a behavioral equivalent of the password-verification code that previously lived inline in ``routes/auth.py`` — moved here so the route layer can call a single :meth:`authenticate` method regardless of which backend is configured. Future-IdP note --------------- When the organization moves to OIDC / SAML / LDAP, this class does not need to be deleted. It can continue to serve as a "local emergency account" provider, configurable side-by-side with the IdP provider via a future composite / multi-provider setup. For now it is the only implementation. """ from __future__ import annotations import logging import os import uuid from pathlib import Path import aiosqlite from ..models import DEFAULT_AUTH_DB_PATH, user_row_to_dict from ..password import hash_password, verify_password from .user import User logger = logging.getLogger(__name__) def _resolve_db_path() -> Path: """Resolve the auth DB path with runtime env-var priority. The :data:`models.DEFAULT_AUTH_DB_PATH` constant is captured at module-import time and therefore cannot see test-time env mutations. Re-reading ``AGENTKIT_AUTH_DB`` here keeps the provider "test-friendly" (tests can ``monkeypatch.setenv`` before constructing the provider) without giving up the default path when no env is set. """ env = os.environ.get("AGENTKIT_AUTH_DB") if env: return Path(env) return DEFAULT_AUTH_DB_PATH class LocalAuthProvider: """AuthProvider backed by the local SQLite ``users`` table + bcrypt. Args: db_path: Path to the auth DB. Defaults to the value of the ``AGENTKIT_AUTH_DB`` env var, falling back to :data:`agentkit.server.auth.models.DEFAULT_AUTH_DB_PATH`. Each operation opens a short-lived aiosqlite connection; the existing route layer follows the same pattern, so no connection pooling is introduced here. If a future deployment needs pooling, swap in a ``db_factory: Callable`` here without changing the protocol. """ name = "local" def __init__(self, db_path: str | Path | None = None) -> None: self._db_path = Path(db_path) if db_path is not None else _resolve_db_path() @property def db_path(self) -> Path: return self._db_path async def authenticate(self, *, username: str, password: str) -> User: """Verify the username + password against the local users table. Raises :class:`InvalidCredentials` on every failure mode (unknown user, wrong password, inactive user) with the same error message — preventing username enumeration via error inspection. Constant-time-equivalent behavior is also ensured by always running a real bcrypt computation (against a dummy hash) when the user does not exist, matching the timing of the "user exists, wrong password" path. """ from .exceptions import InvalidCredentials # local import to avoid cycle at module load async with aiosqlite.connect(str(self._db_path)) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT id, username, email, password_hash, role, is_active, " "is_terminal_authorized, is_server_terminal_authorized, " "created_at, updated_at, last_login_at, created_by " "FROM users WHERE username = ?", (username,), ) row = await cursor.fetchone() if row is None or not bool(row["is_active"]): # Run a real bcrypt verification against a valid-format dummy # hash so the response time matches the "user exists, wrong # password" path (~250ms). Prevents username enumeration via # timing. The dummy hash is invalid (won't match any password) # but has the right shape so bcrypt.checkpw doesn't short-circuit. _DUMMY_BCRYPT_HASH = "$2b$12$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ0123" verify_password(password, _DUMMY_BCRYPT_HASH) raise InvalidCredentials("invalid username or password") if not verify_password(password, row["password_hash"]): raise InvalidCredentials("invalid username or password") return _row_to_user(row) async def get_user_by_id(self, user_id: str) -> User | None: """Look up a user by id. Returns ``None`` if not found or inactive.""" async with aiosqlite.connect(str(self._db_path)) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT id, username, email, password_hash, role, is_active, " "is_terminal_authorized, is_server_terminal_authorized, " "created_at, updated_at, last_login_at, created_by " "FROM users WHERE id = ? AND is_active = 1", (user_id,), ) row = await cursor.fetchone() return _row_to_user(row) if row else None async def sync_user_attributes(self, user_id: str) -> None: """No-op: local provider has no upstream source of truth to sync from.""" return None async def revoke_user(self, user_id: str) -> None: """Disable a user account (``is_active = 0``).""" async with aiosqlite.connect(str(self._db_path)) as db: await db.execute( "UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?", (_now_iso(), user_id), ) await db.commit() logger.info(f"Revoked user {user_id} via LocalAuthProvider") async def create_user( self, username: str, email: str, password: str, role: str = "member", is_terminal_authorized: bool = False, is_server_terminal_authorized: bool = False, created_by: str | None = None, ) -> dict[str, object]: """Create a new user in the local ``users`` table. Args: username: Unique username. email: Unique email address. password: Plain-text password (will be bcrypt-hashed with cost factor 12 before storage). role: Role name (``member`` / ``operator`` / ``admin``). Defaults to ``member``. is_terminal_authorized: Whether the user may use the local terminal. Defaults to ``False``. is_server_terminal_authorized: Whether the user may use the server terminal. Defaults to ``False``. created_by: Optional user id of the admin who created this user (audit trail). Returns: The newly-created user as a dict (via :func:`agentkit.server.auth.models.user_row_to_dict`). Raises: ValueError: If a user with the same username or email already exists (catches SQLite ``IntegrityError`` on the ``username`` / ``email`` UNIQUE constraints). """ user_id = str(uuid.uuid4()) password_hash = hash_password(password) now = _now_iso() try: async with aiosqlite.connect(str(self._db_path)) as db: db.row_factory = aiosqlite.Row await db.execute( "INSERT INTO users " "(id, username, email, password_hash, role, is_active, " " is_terminal_authorized, is_server_terminal_authorized, " " created_at, updated_at, created_by) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( user_id, username, email, password_hash, role, 1, 1 if is_terminal_authorized else 0, 1 if is_server_terminal_authorized else 0, now, now, created_by, ), ) await db.commit() cursor = await db.execute( "SELECT id, username, email, password_hash, role, is_active, " "is_terminal_authorized, is_server_terminal_authorized, " "created_at, updated_at, last_login_at, created_by " "FROM users WHERE id = ?", (user_id,), ) row = await cursor.fetchone() except aiosqlite.IntegrityError as exc: # SQLite IntegrityError message includes the column name; we # inspect it to give the caller a useful error. If for some # reason the message is unparseable, fall back to a generic # "duplicate" message. msg = str(exc).lower() if "username" in msg: raise ValueError(f"User with username {username!r} already exists") from exc if "email" in msg: raise ValueError(f"User with email {email!r} already exists") from exc raise ValueError(f"User already exists: {exc}") from exc assert row is not None # we just inserted it logger.info(f"Created user {username!r} (id={user_id}) via LocalAuthProvider") return user_row_to_dict(row) def _row_to_user(row: aiosqlite.Row) -> User: """Convert a ``users`` row to a :class:`User` value object.""" return User( id=row["id"], username=row["username"], email=row["email"], role=row["role"], is_active=bool(row["is_active"]), is_terminal_authorized=bool(row["is_terminal_authorized"]), is_server_terminal_authorized=bool(row["is_server_terminal_authorized"]), created_at=row["created_at"], updated_at=row["updated_at"], last_login_at=row["last_login_at"], created_by=row["created_by"], ) def _now_iso() -> str: """Return current UTC time as ISO 8601 string.""" from datetime import datetime, timezone return datetime.now(timezone.utc).isoformat()