From 2ffd249aacd0d4631218f2dfa02666419492e257 Mon Sep 17 00:00:00 2001 From: Reinier van der Leer Date: Wed, 10 Sep 2025 11:34:49 +0200 Subject: [PATCH] fix(backend/external-api): Improve security & reliability of API key storage (#10796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our API key generation, storage, and verification system has a couple of issues that need to be ironed out before full-scale deployment. ### Changes 🏗️ - Move from unsalted SHA256 to salted Scrypt hashing for API keys - Avoid false-negative API key validation due to prefix collision - Refactor API key management code for clarity - [refactor(backend): Clean up API key DB & API code (#10797)](https://github.com/Significant-Gravitas/AutoGPT/pull/10797) - Rename models and properties in `backend.data.api_key` for clarity - Eliminate redundant/custom/boilerplate error handling/wrapping in API key endpoint call stack - Remove redundant/inaccurate `response_model` declarations from API key endpoints Dependencies for `autogpt_libs`: - Add `cryptography` as a dependency - Add `pyright` as a dev dependency ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - Performing these actions through the UI (still) works: - [x] Creating an API key - [x] Listing owned API keys - [x] Deleting an owned API key - [x] Newly created API key can be used in Swagger UI - [x] Existing API key can be used in Swagger UI - [x] Existing API key is re-encrypted with salt on use --- .../autogpt_libs/api_key/key_manager.py | 35 -- .../autogpt_libs/api_key/keysmith.py | 78 +++ .../autogpt_libs/api_key/test_keysmith.py | 79 ++++ autogpt_platform/autogpt_libs/poetry.lock | 36 +- autogpt_platform/autogpt_libs/pyproject.toml | 4 +- .../backend/backend/data/api_key.py | 443 +++++++----------- .../backend/server/external/middleware.py | 8 +- .../backend/server/external/routes/v1.py | 8 +- .../backend/backend/server/model.py | 4 +- .../backend/backend/server/rest_api.py | 6 + .../backend/backend/server/routers/v1.py | 128 +---- .../migration.sql | 10 + autogpt_platform/backend/poetry.lock | 1 + autogpt_platform/backend/schema.prisma | 14 +- .../backend/test/e2e_test_data.py | 4 +- .../backend/test/test_data_creator.py | 24 +- .../APIKeySection/APIKeySection.tsx | 2 +- .../APIKeySection/useAPISection.tsx | 7 +- .../frontend/src/app/api/openapi.json | 67 ++- 19 files changed, 484 insertions(+), 474 deletions(-) delete mode 100644 autogpt_platform/autogpt_libs/autogpt_libs/api_key/key_manager.py create mode 100644 autogpt_platform/autogpt_libs/autogpt_libs/api_key/keysmith.py create mode 100644 autogpt_platform/autogpt_libs/autogpt_libs/api_key/test_keysmith.py create mode 100644 autogpt_platform/backend/migrations/20250901104517_fix_api_key_table/migration.sql diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/api_key/key_manager.py b/autogpt_platform/autogpt_libs/autogpt_libs/api_key/key_manager.py deleted file mode 100644 index 0ac5f8793c..0000000000 --- a/autogpt_platform/autogpt_libs/autogpt_libs/api_key/key_manager.py +++ /dev/null @@ -1,35 +0,0 @@ -import hashlib -import secrets -from typing import NamedTuple - - -class APIKeyContainer(NamedTuple): - """Container for API key parts.""" - - raw: str - prefix: str - postfix: str - hash: str - - -class APIKeyManager: - PREFIX: str = "agpt_" - PREFIX_LENGTH: int = 8 - POSTFIX_LENGTH: int = 8 - - def generate_api_key(self) -> APIKeyContainer: - """Generate a new API key with all its parts.""" - raw_key = f"{self.PREFIX}{secrets.token_urlsafe(32)}" - return APIKeyContainer( - raw=raw_key, - prefix=raw_key[: self.PREFIX_LENGTH], - postfix=raw_key[-self.POSTFIX_LENGTH :], - hash=hashlib.sha256(raw_key.encode()).hexdigest(), - ) - - def verify_api_key(self, provided_key: str, stored_hash: str) -> bool: - """Verify if a provided API key matches the stored hash.""" - if not provided_key.startswith(self.PREFIX): - return False - provided_hash = hashlib.sha256(provided_key.encode()).hexdigest() - return secrets.compare_digest(provided_hash, stored_hash) diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/api_key/keysmith.py b/autogpt_platform/autogpt_libs/autogpt_libs/api_key/keysmith.py new file mode 100644 index 0000000000..394044a69d --- /dev/null +++ b/autogpt_platform/autogpt_libs/autogpt_libs/api_key/keysmith.py @@ -0,0 +1,78 @@ +import hashlib +import secrets +from typing import NamedTuple + +from cryptography.hazmat.primitives.kdf.scrypt import Scrypt + + +class APIKeyContainer(NamedTuple): + """Container for API key parts.""" + + key: str + head: str + tail: str + hash: str + salt: str + + +class APIKeySmith: + PREFIX: str = "agpt_" + HEAD_LENGTH: int = 8 + TAIL_LENGTH: int = 8 + + def generate_key(self) -> APIKeyContainer: + """Generate a new API key with secure hashing.""" + raw_key = f"{self.PREFIX}{secrets.token_urlsafe(32)}" + hash, salt = self.hash_key(raw_key) + + return APIKeyContainer( + key=raw_key, + head=raw_key[: self.HEAD_LENGTH], + tail=raw_key[-self.TAIL_LENGTH :], + hash=hash, + salt=salt, + ) + + def verify_key( + self, provided_key: str, known_hash: str, known_salt: str | None = None + ) -> bool: + """ + Verify an API key against a known hash (+ salt). + Supports verifying both legacy SHA256 and secure Scrypt hashes. + """ + if not provided_key.startswith(self.PREFIX): + return False + + # Handle legacy SHA256 hashes (migration support) + if known_salt is None: + legacy_hash = hashlib.sha256(provided_key.encode()).hexdigest() + return secrets.compare_digest(legacy_hash, known_hash) + + try: + salt_bytes = bytes.fromhex(known_salt) + provided_hash = self._hash_key_with_salt(provided_key, salt_bytes) + return secrets.compare_digest(provided_hash, known_hash) + except (ValueError, TypeError): + return False + + def hash_key(self, raw_key: str) -> tuple[str, str]: + """Migrate a legacy hash to secure hash format.""" + salt = self._generate_salt() + hash = self._hash_key_with_salt(raw_key, salt) + return hash, salt.hex() + + def _generate_salt(self) -> bytes: + """Generate a random salt for hashing.""" + return secrets.token_bytes(32) + + def _hash_key_with_salt(self, raw_key: str, salt: bytes) -> str: + """Hash API key using Scrypt with salt.""" + kdf = Scrypt( + length=32, + salt=salt, + n=2**14, # CPU/memory cost parameter + r=8, # Block size parameter + p=1, # Parallelization parameter + ) + key_hash = kdf.derive(raw_key.encode()) + return key_hash.hex() diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/api_key/test_keysmith.py b/autogpt_platform/autogpt_libs/autogpt_libs/api_key/test_keysmith.py new file mode 100644 index 0000000000..af7a3f6080 --- /dev/null +++ b/autogpt_platform/autogpt_libs/autogpt_libs/api_key/test_keysmith.py @@ -0,0 +1,79 @@ +import hashlib + +from autogpt_libs.api_key.keysmith import APIKeySmith + + +def test_generate_api_key(): + keysmith = APIKeySmith() + key = keysmith.generate_key() + + assert key.key.startswith(keysmith.PREFIX) + assert key.head == key.key[: keysmith.HEAD_LENGTH] + assert key.tail == key.key[-keysmith.TAIL_LENGTH :] + assert len(key.hash) == 64 # 32 bytes hex encoded + assert len(key.salt) == 64 # 32 bytes hex encoded + + +def test_verify_new_secure_key(): + keysmith = APIKeySmith() + key = keysmith.generate_key() + + # Test correct key validates + assert keysmith.verify_key(key.key, key.hash, key.salt) is True + + # Test wrong key fails + wrong_key = f"{keysmith.PREFIX}wrongkey123" + assert keysmith.verify_key(wrong_key, key.hash, key.salt) is False + + +def test_verify_legacy_key(): + keysmith = APIKeySmith() + legacy_key = f"{keysmith.PREFIX}legacykey123" + legacy_hash = hashlib.sha256(legacy_key.encode()).hexdigest() + + # Test legacy key validates without salt + assert keysmith.verify_key(legacy_key, legacy_hash) is True + + # Test wrong legacy key fails + wrong_key = f"{keysmith.PREFIX}wronglegacy" + assert keysmith.verify_key(wrong_key, legacy_hash) is False + + +def test_rehash_existing_key(): + keysmith = APIKeySmith() + legacy_key = f"{keysmith.PREFIX}migratekey123" + + # Migrate the legacy key + new_hash, new_salt = keysmith.hash_key(legacy_key) + + # Verify migrated key works + assert keysmith.verify_key(legacy_key, new_hash, new_salt) is True + + # Verify different key fails with migrated hash + wrong_key = f"{keysmith.PREFIX}wrongkey" + assert keysmith.verify_key(wrong_key, new_hash, new_salt) is False + + +def test_invalid_key_prefix(): + keysmith = APIKeySmith() + key = keysmith.generate_key() + + # Test key without proper prefix fails + invalid_key = "invalid_prefix_key" + assert keysmith.verify_key(invalid_key, key.hash, key.salt) is False + + +def test_secure_hash_requires_salt(): + keysmith = APIKeySmith() + key = keysmith.generate_key() + + # Secure hash without salt should fail + assert keysmith.verify_key(key.key, key.hash) is False + + +def test_invalid_salt_format(): + keysmith = APIKeySmith() + key = keysmith.generate_key() + + # Invalid salt format should fail gracefully + assert keysmith.verify_key(key.key, key.hash, "invalid_hex") is False diff --git a/autogpt_platform/autogpt_libs/poetry.lock b/autogpt_platform/autogpt_libs/poetry.lock index 9fbde32b79..f4f97cc65a 100644 --- a/autogpt_platform/autogpt_libs/poetry.lock +++ b/autogpt_platform/autogpt_libs/poetry.lock @@ -1002,6 +1002,18 @@ dynamodb = ["boto3 (>=1.9.71)"] redis = ["redis (>=2.10.5)"] test-filesource = ["pyyaml (>=5.3.1)", "watchdog (>=3.0.0)"] +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + [[package]] name = "opentelemetry-api" version = "1.35.0" @@ -1347,6 +1359,27 @@ files = [ {file = "pyrfc3339-2.0.1.tar.gz", hash = "sha256:e47843379ea35c1296c3b6c67a948a1a490ae0584edfcbdea0eaffb5dd29960b"}, ] +[[package]] +name = "pyright" +version = "1.1.404" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pyright-1.1.404-py3-none-any.whl", hash = "sha256:c7b7ff1fdb7219c643079e4c3e7d4125f0dafcc19d253b47e898d130ea426419"}, + {file = "pyright-1.1.404.tar.gz", hash = "sha256:455e881a558ca6be9ecca0b30ce08aa78343ecc031d37a198ffa9a7a1abeb63e"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + [[package]] name = "pytest" version = "8.4.1" @@ -1740,7 +1773,6 @@ files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] -markers = {dev = "python_version < \"3.11\""} [[package]] name = "typing-inspection" @@ -1897,4 +1929,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "d841f62f95180f6ad63ce82ed8e62aa201b9bf89242cc9299ae0f26ff1f72136" +content-hash = "0c40b63c3c921846cf05ccfb4e685d4959854b29c2c302245f9832e20aac6954" diff --git a/autogpt_platform/autogpt_libs/pyproject.toml b/autogpt_platform/autogpt_libs/pyproject.toml index 6ad93d2607..a81b07fba6 100644 --- a/autogpt_platform/autogpt_libs/pyproject.toml +++ b/autogpt_platform/autogpt_libs/pyproject.toml @@ -9,6 +9,7 @@ packages = [{ include = "autogpt_libs" }] [tool.poetry.dependencies] python = ">=3.10,<4.0" colorama = "^0.4.6" +cryptography = "^45.0" expiringdict = "^1.2.2" fastapi = "^0.116.1" google-cloud-logging = "^3.12.1" @@ -21,11 +22,12 @@ supabase = "^2.16.0" uvicorn = "^0.35.0" [tool.poetry.group.dev.dependencies] -ruff = "^0.12.11" +pyright = "^1.1.404" pytest = "^8.4.1" pytest-asyncio = "^1.1.0" pytest-mock = "^3.14.1" pytest-cov = "^6.2.1" +ruff = "^0.12.11" [build-system] requires = ["poetry-core"] diff --git a/autogpt_platform/backend/backend/data/api_key.py b/autogpt_platform/backend/backend/data/api_key.py index 91fb33622e..ac00b98522 100644 --- a/autogpt_platform/backend/backend/data/api_key.py +++ b/autogpt_platform/backend/backend/data/api_key.py @@ -1,57 +1,31 @@ import logging import uuid from datetime import datetime, timezone -from typing import List, Optional +from typing import Optional -from autogpt_libs.api_key.key_manager import APIKeyManager +from autogpt_libs.api_key.keysmith import APIKeySmith from prisma.enums import APIKeyPermission, APIKeyStatus -from prisma.errors import PrismaError from prisma.models import APIKey as PrismaAPIKey -from prisma.types import ( - APIKeyCreateInput, - APIKeyUpdateInput, - APIKeyWhereInput, - APIKeyWhereUniqueInput, -) -from pydantic import BaseModel +from prisma.types import APIKeyWhereUniqueInput +from pydantic import BaseModel, Field -from backend.data.db import BaseDbModel +from backend.util.exceptions import NotAuthorizedError, NotFoundError logger = logging.getLogger(__name__) +keysmith = APIKeySmith() -# Some basic exceptions -class APIKeyError(Exception): - """Base exception for API key operations""" - - pass - - -class APIKeyNotFoundError(APIKeyError): - """Raised when an API key is not found""" - - pass - - -class APIKeyPermissionError(APIKeyError): - """Raised when there are permission issues with API key operations""" - - pass - - -class APIKeyValidationError(APIKeyError): - """Raised when API key validation fails""" - - pass - - -class APIKey(BaseDbModel): +class APIKeyInfo(BaseModel): + id: str name: str - prefix: str - key: str - status: APIKeyStatus = APIKeyStatus.ACTIVE - permissions: List[APIKeyPermission] - postfix: str + head: str = Field( + description=f"The first {APIKeySmith.HEAD_LENGTH} characters of the key" + ) + tail: str = Field( + description=f"The last {APIKeySmith.TAIL_LENGTH} characters of the key" + ) + status: APIKeyStatus + permissions: list[APIKeyPermission] created_at: datetime last_used_at: Optional[datetime] = None revoked_at: Optional[datetime] = None @@ -60,266 +34,211 @@ class APIKey(BaseDbModel): @staticmethod def from_db(api_key: PrismaAPIKey): - try: - return APIKey( - id=api_key.id, - name=api_key.name, - prefix=api_key.prefix, - postfix=api_key.postfix, - key=api_key.key, - status=APIKeyStatus(api_key.status), - permissions=[APIKeyPermission(p) for p in api_key.permissions], - created_at=api_key.createdAt, - last_used_at=api_key.lastUsedAt, - revoked_at=api_key.revokedAt, - description=api_key.description, - user_id=api_key.userId, - ) - except Exception as e: - logger.error(f"Error creating APIKey from db: {str(e)}") - raise APIKeyError(f"Failed to create API key object: {str(e)}") + return APIKeyInfo( + id=api_key.id, + name=api_key.name, + head=api_key.head, + tail=api_key.tail, + status=APIKeyStatus(api_key.status), + permissions=[APIKeyPermission(p) for p in api_key.permissions], + created_at=api_key.createdAt, + last_used_at=api_key.lastUsedAt, + revoked_at=api_key.revokedAt, + description=api_key.description, + user_id=api_key.userId, + ) -class APIKeyWithoutHash(BaseModel): - id: str - name: str - prefix: str - postfix: str - status: APIKeyStatus - permissions: List[APIKeyPermission] - created_at: datetime - last_used_at: Optional[datetime] - revoked_at: Optional[datetime] - description: Optional[str] - user_id: str +class APIKeyInfoWithHash(APIKeyInfo): + hash: str + salt: str | None = None # None for legacy keys + + def match(self, plaintext_key: str) -> bool: + """Returns whether the given key matches this API key object.""" + return keysmith.verify_key(plaintext_key, self.hash, self.salt) @staticmethod def from_db(api_key: PrismaAPIKey): - try: - return APIKeyWithoutHash( - id=api_key.id, - name=api_key.name, - prefix=api_key.prefix, - postfix=api_key.postfix, - status=APIKeyStatus(api_key.status), - permissions=[APIKeyPermission(p) for p in api_key.permissions], - created_at=api_key.createdAt, - last_used_at=api_key.lastUsedAt, - revoked_at=api_key.revokedAt, - description=api_key.description, - user_id=api_key.userId, - ) - except Exception as e: - logger.error(f"Error creating APIKeyWithoutHash from db: {str(e)}") - raise APIKeyError(f"Failed to create API key object: {str(e)}") + return APIKeyInfoWithHash( + **APIKeyInfo.from_db(api_key).model_dump(), + hash=api_key.hash, + salt=api_key.salt, + ) + + def without_hash(self) -> APIKeyInfo: + return APIKeyInfo(**self.model_dump(exclude={"hash", "salt"})) -async def generate_api_key( +async def create_api_key( name: str, user_id: str, - permissions: List[APIKeyPermission], + permissions: list[APIKeyPermission], description: Optional[str] = None, -) -> tuple[APIKeyWithoutHash, str]: +) -> tuple[APIKeyInfo, str]: """ Generate a new API key and store it in the database. Returns the API key object (without hash) and the plain text key. """ - try: - api_manager = APIKeyManager() - key = api_manager.generate_api_key() + generated_key = keysmith.generate_key() - api_key = await PrismaAPIKey.prisma().create( - data=APIKeyCreateInput( - id=str(uuid.uuid4()), - name=name, - prefix=key.prefix, - postfix=key.postfix, - key=key.hash, - permissions=[p for p in permissions], - description=description, - userId=user_id, - ) - ) + saved_key_obj = await PrismaAPIKey.prisma().create( + data={ + "id": str(uuid.uuid4()), + "name": name, + "head": generated_key.head, + "tail": generated_key.tail, + "hash": generated_key.hash, + "salt": generated_key.salt, + "permissions": [p for p in permissions], + "description": description, + "userId": user_id, + } + ) - api_key_without_hash = APIKeyWithoutHash.from_db(api_key) - return api_key_without_hash, key.raw - except PrismaError as e: - logger.error(f"Database error while generating API key: {str(e)}") - raise APIKeyError(f"Failed to generate API key: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error while generating API key: {str(e)}") - raise APIKeyError(f"Failed to generate API key: {str(e)}") + return APIKeyInfo.from_db(saved_key_obj), generated_key.key -async def validate_api_key(plain_text_key: str) -> Optional[APIKey]: +async def get_active_api_keys_by_head(head: str) -> list[APIKeyInfoWithHash]: + results = await PrismaAPIKey.prisma().find_many( + where={"head": head, "status": APIKeyStatus.ACTIVE} + ) + return [APIKeyInfoWithHash.from_db(key) for key in results] + + +async def validate_api_key(plaintext_key: str) -> Optional[APIKeyInfo]: """ - Validate an API key and return the API key object if valid. + Validate an API key and return the API key object if valid and active. """ try: - if not plain_text_key.startswith(APIKeyManager.PREFIX): + if not plaintext_key.startswith(APIKeySmith.PREFIX): logger.warning("Invalid API key format") return None - prefix = plain_text_key[: APIKeyManager.PREFIX_LENGTH] - api_manager = APIKeyManager() + head = plaintext_key[: APIKeySmith.HEAD_LENGTH] + potential_matches = await get_active_api_keys_by_head(head) - api_key = await PrismaAPIKey.prisma().find_first( - where=APIKeyWhereInput(prefix=prefix, status=(APIKeyStatus.ACTIVE)) + matched_api_key = next( + (pm for pm in potential_matches if pm.match(plaintext_key)), + None, ) - - if not api_key: - logger.warning(f"No active API key found with prefix {prefix}") + if not matched_api_key: + # API key not found or invalid return None - is_valid = api_manager.verify_api_key(plain_text_key, api_key.key) - if not is_valid: - logger.warning("API key verification failed") - return None - - return APIKey.from_db(api_key) - except Exception as e: - logger.error(f"Error validating API key: {str(e)}") - raise APIKeyValidationError(f"Failed to validate API key: {str(e)}") - - -async def revoke_api_key(key_id: str, user_id: str) -> Optional[APIKeyWithoutHash]: - try: - api_key = await PrismaAPIKey.prisma().find_unique(where={"id": key_id}) - - if not api_key: - raise APIKeyNotFoundError(f"API key with id {key_id} not found") - - if api_key.userId != user_id: - raise APIKeyPermissionError( - "You do not have permission to revoke this API key." + # Migrate legacy keys to secure format on successful validation + if matched_api_key.salt is None: + matched_api_key = await _migrate_key_to_secure_hash( + plaintext_key, matched_api_key ) - where_clause: APIKeyWhereUniqueInput = {"id": key_id} - updated_api_key = await PrismaAPIKey.prisma().update( - where=where_clause, - data=APIKeyUpdateInput( - status=APIKeyStatus.REVOKED, revokedAt=datetime.now(timezone.utc) - ), - ) + return matched_api_key.without_hash() + except Exception as e: + logger.error(f"Error while validating API key: {e}") + raise RuntimeError("Failed to validate API key") from e - if updated_api_key: - return APIKeyWithoutHash.from_db(updated_api_key) + +async def _migrate_key_to_secure_hash( + plaintext_key: str, key_obj: APIKeyInfoWithHash +) -> APIKeyInfoWithHash: + """Replace the SHA256 hash of a legacy API key with a salted Scrypt hash.""" + try: + new_hash, new_salt = keysmith.hash_key(plaintext_key) + await PrismaAPIKey.prisma().update( + where={"id": key_obj.id}, data={"hash": new_hash, "salt": new_salt} + ) + logger.info(f"Migrated legacy API key #{key_obj.id} to secure format") + # Update the API key object with new values for return + key_obj.hash = new_hash + key_obj.salt = new_salt + except Exception as e: + logger.error(f"Failed to migrate legacy API key #{key_obj.id}: {e}") + + return key_obj + + +async def revoke_api_key(key_id: str, user_id: str) -> APIKeyInfo: + api_key = await PrismaAPIKey.prisma().find_unique(where={"id": key_id}) + + if not api_key: + raise NotFoundError(f"API key with id {key_id} not found") + + if api_key.userId != user_id: + raise NotAuthorizedError("You do not have permission to revoke this API key.") + + updated_api_key = await PrismaAPIKey.prisma().update( + where={"id": key_id}, + data={ + "status": APIKeyStatus.REVOKED, + "revokedAt": datetime.now(timezone.utc), + }, + ) + if not updated_api_key: + raise NotFoundError(f"API key #{key_id} vanished while trying to revoke.") + + return APIKeyInfo.from_db(updated_api_key) + + +async def list_user_api_keys(user_id: str) -> list[APIKeyInfo]: + api_keys = await PrismaAPIKey.prisma().find_many( + where={"userId": user_id}, order={"createdAt": "desc"} + ) + + return [APIKeyInfo.from_db(key) for key in api_keys] + + +async def suspend_api_key(key_id: str, user_id: str) -> APIKeyInfo: + selector: APIKeyWhereUniqueInput = {"id": key_id} + api_key = await PrismaAPIKey.prisma().find_unique(where=selector) + + if not api_key: + raise NotFoundError(f"API key with id {key_id} not found") + + if api_key.userId != user_id: + raise NotAuthorizedError("You do not have permission to suspend this API key.") + + updated_api_key = await PrismaAPIKey.prisma().update( + where=selector, data={"status": APIKeyStatus.SUSPENDED} + ) + if not updated_api_key: + raise NotFoundError(f"API key #{key_id} vanished while trying to suspend.") + + return APIKeyInfo.from_db(updated_api_key) + + +def has_permission(api_key: APIKeyInfo, required_permission: APIKeyPermission) -> bool: + return required_permission in api_key.permissions + + +async def get_api_key_by_id(key_id: str, user_id: str) -> Optional[APIKeyInfo]: + api_key = await PrismaAPIKey.prisma().find_first( + where={"id": key_id, "userId": user_id} + ) + + if not api_key: return None - except (APIKeyNotFoundError, APIKeyPermissionError) as e: - raise e - except PrismaError as e: - logger.error(f"Database error while revoking API key: {str(e)}") - raise APIKeyError(f"Failed to revoke API key: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error while revoking API key: {str(e)}") - raise APIKeyError(f"Failed to revoke API key: {str(e)}") - -async def list_user_api_keys(user_id: str) -> List[APIKeyWithoutHash]: - try: - where_clause: APIKeyWhereInput = {"userId": user_id} - - api_keys = await PrismaAPIKey.prisma().find_many( - where=where_clause, order={"createdAt": "desc"} - ) - - return [APIKeyWithoutHash.from_db(key) for key in api_keys] - except PrismaError as e: - logger.error(f"Database error while listing API keys: {str(e)}") - raise APIKeyError(f"Failed to list API keys: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error while listing API keys: {str(e)}") - raise APIKeyError(f"Failed to list API keys: {str(e)}") - - -async def suspend_api_key(key_id: str, user_id: str) -> Optional[APIKeyWithoutHash]: - try: - api_key = await PrismaAPIKey.prisma().find_unique(where={"id": key_id}) - - if not api_key: - raise APIKeyNotFoundError(f"API key with id {key_id} not found") - - if api_key.userId != user_id: - raise APIKeyPermissionError( - "You do not have permission to suspend this API key." - ) - - where_clause: APIKeyWhereUniqueInput = {"id": key_id} - updated_api_key = await PrismaAPIKey.prisma().update( - where=where_clause, - data=APIKeyUpdateInput(status=APIKeyStatus.SUSPENDED), - ) - - if updated_api_key: - return APIKeyWithoutHash.from_db(updated_api_key) - return None - except (APIKeyNotFoundError, APIKeyPermissionError) as e: - raise e - except PrismaError as e: - logger.error(f"Database error while suspending API key: {str(e)}") - raise APIKeyError(f"Failed to suspend API key: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error while suspending API key: {str(e)}") - raise APIKeyError(f"Failed to suspend API key: {str(e)}") - - -def has_permission(api_key: APIKey, required_permission: APIKeyPermission) -> bool: - try: - return required_permission in api_key.permissions - except Exception as e: - logger.error(f"Error checking API key permissions: {str(e)}") - return False - - -async def get_api_key_by_id(key_id: str, user_id: str) -> Optional[APIKeyWithoutHash]: - try: - api_key = await PrismaAPIKey.prisma().find_first( - where=APIKeyWhereInput(id=key_id, userId=user_id) - ) - - if not api_key: - return None - - return APIKeyWithoutHash.from_db(api_key) - except PrismaError as e: - logger.error(f"Database error while getting API key: {str(e)}") - raise APIKeyError(f"Failed to get API key: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error while getting API key: {str(e)}") - raise APIKeyError(f"Failed to get API key: {str(e)}") + return APIKeyInfo.from_db(api_key) async def update_api_key_permissions( - key_id: str, user_id: str, permissions: List[APIKeyPermission] -) -> Optional[APIKeyWithoutHash]: + key_id: str, user_id: str, permissions: list[APIKeyPermission] +) -> APIKeyInfo: """ Update the permissions of an API key. """ - try: - api_key = await PrismaAPIKey.prisma().find_unique(where={"id": key_id}) + api_key = await PrismaAPIKey.prisma().find_unique(where={"id": key_id}) - if api_key is None: - raise APIKeyNotFoundError("No such API key found.") + if api_key is None: + raise NotFoundError("No such API key found.") - if api_key.userId != user_id: - raise APIKeyPermissionError( - "You do not have permission to update this API key." - ) + if api_key.userId != user_id: + raise NotAuthorizedError("You do not have permission to update this API key.") - where_clause: APIKeyWhereUniqueInput = {"id": key_id} - updated_api_key = await PrismaAPIKey.prisma().update( - where=where_clause, - data=APIKeyUpdateInput(permissions=permissions), - ) + updated_api_key = await PrismaAPIKey.prisma().update( + where={"id": key_id}, + data={"permissions": permissions}, + ) + if not updated_api_key: + raise NotFoundError(f"API key #{key_id} vanished while trying to update.") - if updated_api_key: - return APIKeyWithoutHash.from_db(updated_api_key) - return None - except (APIKeyNotFoundError, APIKeyPermissionError) as e: - raise e - except PrismaError as e: - logger.error(f"Database error while updating API key permissions: {str(e)}") - raise APIKeyError(f"Failed to update API key permissions: {str(e)}") - except Exception as e: - logger.error(f"Unexpected error while updating API key permissions: {str(e)}") - raise APIKeyError(f"Failed to update API key permissions: {str(e)}") + return APIKeyInfo.from_db(updated_api_key) diff --git a/autogpt_platform/backend/backend/server/external/middleware.py b/autogpt_platform/backend/backend/server/external/middleware.py index 087c10b661..af84c92687 100644 --- a/autogpt_platform/backend/backend/server/external/middleware.py +++ b/autogpt_platform/backend/backend/server/external/middleware.py @@ -2,12 +2,12 @@ from fastapi import HTTPException, Security from fastapi.security import APIKeyHeader from prisma.enums import APIKeyPermission -from backend.data.api_key import APIKey, has_permission, validate_api_key +from backend.data.api_key import APIKeyInfo, has_permission, validate_api_key api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) -async def require_api_key(api_key: str | None = Security(api_key_header)) -> APIKey: +async def require_api_key(api_key: str | None = Security(api_key_header)) -> APIKeyInfo: """Base middleware for API key authentication""" if api_key is None: raise HTTPException(status_code=401, detail="Missing API key") @@ -23,7 +23,9 @@ async def require_api_key(api_key: str | None = Security(api_key_header)) -> API def require_permission(permission: APIKeyPermission): """Dependency function for checking specific permissions""" - async def check_permission(api_key: APIKey = Security(require_api_key)): + async def check_permission( + api_key: APIKeyInfo = Security(require_api_key), + ) -> APIKeyInfo: if not has_permission(api_key, permission): raise HTTPException( status_code=403, diff --git a/autogpt_platform/backend/backend/server/external/routes/v1.py b/autogpt_platform/backend/backend/server/external/routes/v1.py index 7d695053fe..b5fc50a190 100644 --- a/autogpt_platform/backend/backend/server/external/routes/v1.py +++ b/autogpt_platform/backend/backend/server/external/routes/v1.py @@ -9,7 +9,7 @@ from typing_extensions import TypedDict import backend.data.block from backend.data import execution as execution_db from backend.data import graph as graph_db -from backend.data.api_key import APIKey +from backend.data.api_key import APIKeyInfo from backend.data.block import BlockInput, CompletedBlockOutput from backend.executor.utils import add_graph_execution from backend.server.external.middleware import require_permission @@ -62,7 +62,7 @@ def get_graph_blocks() -> Sequence[dict[Any, Any]]: async def execute_graph_block( block_id: str, data: BlockInput, - api_key: APIKey = Security(require_permission(APIKeyPermission.EXECUTE_BLOCK)), + api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.EXECUTE_BLOCK)), ) -> CompletedBlockOutput: obj = backend.data.block.get_block(block_id) if not obj: @@ -82,7 +82,7 @@ async def execute_graph( graph_id: str, graph_version: int, node_input: Annotated[dict[str, Any], Body(..., embed=True, default_factory=dict)], - api_key: APIKey = Security(require_permission(APIKeyPermission.EXECUTE_GRAPH)), + api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.EXECUTE_GRAPH)), ) -> dict[str, Any]: try: graph_exec = await add_graph_execution( @@ -104,7 +104,7 @@ async def execute_graph( async def get_graph_execution_results( graph_id: str, graph_exec_id: str, - api_key: APIKey = Security(require_permission(APIKeyPermission.READ_GRAPH)), + api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.READ_GRAPH)), ) -> GraphExecutionResult: graph = await graph_db.get_graph(graph_id, user_id=api_key.user_id) if not graph: diff --git a/autogpt_platform/backend/backend/server/model.py b/autogpt_platform/backend/backend/server/model.py index 482dd6be9c..bbb904a794 100644 --- a/autogpt_platform/backend/backend/server/model.py +++ b/autogpt_platform/backend/backend/server/model.py @@ -3,7 +3,7 @@ from typing import Any, Optional import pydantic -from backend.data.api_key import APIKeyPermission, APIKeyWithoutHash +from backend.data.api_key import APIKeyInfo, APIKeyPermission from backend.data.graph import Graph from backend.util.timezone_name import TimeZoneName @@ -45,7 +45,7 @@ class CreateAPIKeyRequest(pydantic.BaseModel): class CreateAPIKeyResponse(pydantic.BaseModel): - api_key: APIKeyWithoutHash + api_key: APIKeyInfo plain_text_key: str diff --git a/autogpt_platform/backend/backend/server/rest_api.py b/autogpt_platform/backend/backend/server/rest_api.py index 6de8889ccb..db2796c50f 100644 --- a/autogpt_platform/backend/backend/server/rest_api.py +++ b/autogpt_platform/backend/backend/server/rest_api.py @@ -12,6 +12,7 @@ from autogpt_libs.auth import add_auth_responses_to_openapi from autogpt_libs.auth import verify_settings as verify_auth_settings from fastapi.exceptions import RequestValidationError from fastapi.routing import APIRoute +from prisma.errors import PrismaError import backend.data.block import backend.data.db @@ -39,6 +40,7 @@ from backend.server.external.api import external_app from backend.server.middleware.security import SecurityHeadersMiddleware from backend.util import json from backend.util.cloud_storage import shutdown_cloud_storage_handler +from backend.util.exceptions import NotAuthorizedError, NotFoundError from backend.util.feature_flag import initialize_launchdarkly, shutdown_launchdarkly from backend.util.service import UnhealthyServiceError @@ -195,10 +197,14 @@ async def validation_error_handler( ) +app.add_exception_handler(PrismaError, handle_internal_http_error(500)) +app.add_exception_handler(NotFoundError, handle_internal_http_error(404, False)) +app.add_exception_handler(NotAuthorizedError, handle_internal_http_error(403, False)) app.add_exception_handler(RequestValidationError, validation_error_handler) app.add_exception_handler(pydantic.ValidationError, validation_error_handler) app.add_exception_handler(ValueError, handle_internal_http_error(400)) app.add_exception_handler(Exception, handle_internal_http_error(500)) + app.include_router(backend.server.routers.v1.v1_router, tags=["v1"], prefix="/api") app.include_router( backend.server.v2.store.routes.router, tags=["v2"], prefix="/api/store" diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index c5c7d818e3..6c6c20f320 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -27,20 +27,9 @@ from typing_extensions import Optional, TypedDict import backend.server.integrations.router import backend.server.routers.analytics import backend.server.v2.library.db as library_db +from backend.data import api_key as api_key_db from backend.data import execution as execution_db from backend.data import graph as graph_db -from backend.data.api_key import ( - APIKeyError, - APIKeyNotFoundError, - APIKeyPermissionError, - APIKeyWithoutHash, - generate_api_key, - get_api_key_by_id, - list_user_api_keys, - revoke_api_key, - suspend_api_key, - update_api_key_permissions, -) from backend.data.block import BlockInput, CompletedBlockOutput, get_block, get_blocks from backend.data.credit import ( AutoTopUpConfig, @@ -176,7 +165,6 @@ async def get_user_timezone_route( summary="Update user timezone", tags=["auth"], dependencies=[Security(requires_user)], - response_model=TimezoneResponse, ) async def update_user_timezone_route( user_id: Annotated[str, Security(get_user_id)], request: UpdateTimezoneRequest @@ -1082,7 +1070,6 @@ async def delete_graph_execution_schedule( @v1_router.post( "/api-keys", summary="Create new API key", - response_model=CreateAPIKeyResponse, tags=["api-keys"], dependencies=[Security(requires_user)], ) @@ -1090,128 +1077,73 @@ async def create_api_key( request: CreateAPIKeyRequest, user_id: Annotated[str, Security(get_user_id)] ) -> CreateAPIKeyResponse: """Create a new API key""" - try: - api_key, plain_text = await generate_api_key( - name=request.name, - user_id=user_id, - permissions=request.permissions, - description=request.description, - ) - return CreateAPIKeyResponse(api_key=api_key, plain_text_key=plain_text) - except APIKeyError as e: - logger.error( - "Could not create API key for user %s: %s. Review input and permissions.", - user_id, - e, - ) - raise HTTPException( - status_code=400, - detail={"message": str(e), "hint": "Verify request payload and try again."}, - ) + api_key_info, plain_text_key = await api_key_db.create_api_key( + name=request.name, + user_id=user_id, + permissions=request.permissions, + description=request.description, + ) + return CreateAPIKeyResponse(api_key=api_key_info, plain_text_key=plain_text_key) @v1_router.get( "/api-keys", summary="List user API keys", - response_model=list[APIKeyWithoutHash] | dict[str, str], tags=["api-keys"], dependencies=[Security(requires_user)], ) async def get_api_keys( user_id: Annotated[str, Security(get_user_id)], -) -> list[APIKeyWithoutHash]: +) -> list[api_key_db.APIKeyInfo]: """List all API keys for the user""" - try: - return await list_user_api_keys(user_id) - except APIKeyError as e: - logger.error("Failed to list API keys for user %s: %s", user_id, e) - raise HTTPException( - status_code=400, - detail={"message": str(e), "hint": "Check API key service availability."}, - ) + return await api_key_db.list_user_api_keys(user_id) @v1_router.get( "/api-keys/{key_id}", summary="Get specific API key", - response_model=APIKeyWithoutHash, tags=["api-keys"], dependencies=[Security(requires_user)], ) async def get_api_key( key_id: str, user_id: Annotated[str, Security(get_user_id)] -) -> APIKeyWithoutHash: +) -> api_key_db.APIKeyInfo: """Get a specific API key""" - try: - api_key = await get_api_key_by_id(key_id, user_id) - if not api_key: - raise HTTPException(status_code=404, detail="API key not found") - return api_key - except APIKeyError as e: - logger.error("Error retrieving API key %s for user %s: %s", key_id, user_id, e) - raise HTTPException( - status_code=400, - detail={"message": str(e), "hint": "Ensure the key ID is correct."}, - ) + api_key = await api_key_db.get_api_key_by_id(key_id, user_id) + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + return api_key @v1_router.delete( "/api-keys/{key_id}", summary="Revoke API key", - response_model=APIKeyWithoutHash, tags=["api-keys"], dependencies=[Security(requires_user)], ) async def delete_api_key( key_id: str, user_id: Annotated[str, Security(get_user_id)] -) -> Optional[APIKeyWithoutHash]: +) -> api_key_db.APIKeyInfo: """Revoke an API key""" - try: - return await revoke_api_key(key_id, user_id) - except APIKeyNotFoundError: - raise HTTPException(status_code=404, detail="API key not found") - except APIKeyPermissionError: - raise HTTPException(status_code=403, detail="Permission denied") - except APIKeyError as e: - logger.error("Failed to revoke API key %s for user %s: %s", key_id, user_id, e) - raise HTTPException( - status_code=400, - detail={ - "message": str(e), - "hint": "Verify permissions or try again later.", - }, - ) + return await api_key_db.revoke_api_key(key_id, user_id) @v1_router.post( "/api-keys/{key_id}/suspend", summary="Suspend API key", - response_model=APIKeyWithoutHash, tags=["api-keys"], dependencies=[Security(requires_user)], ) async def suspend_key( key_id: str, user_id: Annotated[str, Security(get_user_id)] -) -> Optional[APIKeyWithoutHash]: +) -> api_key_db.APIKeyInfo: """Suspend an API key""" - try: - return await suspend_api_key(key_id, user_id) - except APIKeyNotFoundError: - raise HTTPException(status_code=404, detail="API key not found") - except APIKeyPermissionError: - raise HTTPException(status_code=403, detail="Permission denied") - except APIKeyError as e: - logger.error("Failed to suspend API key %s for user %s: %s", key_id, user_id, e) - raise HTTPException( - status_code=400, - detail={"message": str(e), "hint": "Check user permissions and retry."}, - ) + return await api_key_db.suspend_api_key(key_id, user_id) @v1_router.put( "/api-keys/{key_id}/permissions", summary="Update key permissions", - response_model=APIKeyWithoutHash, tags=["api-keys"], dependencies=[Security(requires_user)], ) @@ -1219,22 +1151,8 @@ async def update_permissions( key_id: str, request: UpdatePermissionsRequest, user_id: Annotated[str, Security(get_user_id)], -) -> Optional[APIKeyWithoutHash]: +) -> api_key_db.APIKeyInfo: """Update API key permissions""" - try: - return await update_api_key_permissions(key_id, user_id, request.permissions) - except APIKeyNotFoundError: - raise HTTPException(status_code=404, detail="API key not found") - except APIKeyPermissionError: - raise HTTPException(status_code=403, detail="Permission denied") - except APIKeyError as e: - logger.error( - "Failed to update permissions for API key %s of user %s: %s", - key_id, - user_id, - e, - ) - raise HTTPException( - status_code=400, - detail={"message": str(e), "hint": "Ensure permissions list is valid."}, - ) + return await api_key_db.update_api_key_permissions( + key_id, user_id, request.permissions + ) diff --git a/autogpt_platform/backend/migrations/20250901104517_fix_api_key_table/migration.sql b/autogpt_platform/backend/migrations/20250901104517_fix_api_key_table/migration.sql new file mode 100644 index 0000000000..5abeed2094 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250901104517_fix_api_key_table/migration.sql @@ -0,0 +1,10 @@ +-- These changes are part of improvements to our API key system. +-- See https://github.com/Significant-Gravitas/AutoGPT/pull/10796 for context. + +-- Add 'salt' column for Scrypt hashing +ALTER TABLE "APIKey" ADD COLUMN "salt" TEXT; + +-- Rename columns for clarity +ALTER TABLE "APIKey" RENAME COLUMN "key" TO "hash"; +ALTER TABLE "APIKey" RENAME COLUMN "prefix" TO "head"; +ALTER TABLE "APIKey" RENAME COLUMN "postfix" TO "tail"; diff --git a/autogpt_platform/backend/poetry.lock b/autogpt_platform/backend/poetry.lock index f15716e16a..cfb05fae17 100644 --- a/autogpt_platform/backend/poetry.lock +++ b/autogpt_platform/backend/poetry.lock @@ -403,6 +403,7 @@ develop = true [package.dependencies] colorama = "^0.4.6" +cryptography = "^45.0" expiringdict = "^1.2.2" fastapi = "^0.116.1" google-cloud-logging = "^3.12.1" diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index 9b3382e081..d3945659ee 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -831,11 +831,13 @@ enum APIKeyPermission { } model APIKey { - id String @id @default(uuid()) - name String - prefix String // First 8 chars for identification - postfix String - key String @unique // Hashed key + id String @id @default(uuid()) + name String + head String // First few chars for identification + tail String + hash String @unique + salt String? // null for legacy unsalted keys + status APIKeyStatus @default(ACTIVE) permissions APIKeyPermission[] @@ -849,7 +851,7 @@ model APIKey { userId String User User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@index([prefix, name]) + @@index([head, name]) @@index([userId, status]) } diff --git a/autogpt_platform/backend/test/e2e_test_data.py b/autogpt_platform/backend/test/e2e_test_data.py index b183d83d59..0a6cceb567 100644 --- a/autogpt_platform/backend/test/e2e_test_data.py +++ b/autogpt_platform/backend/test/e2e_test_data.py @@ -23,7 +23,7 @@ from typing import Any, Dict, List from faker import Faker -from backend.data.api_key import generate_api_key +from backend.data.api_key import create_api_key from backend.data.credit import get_user_credit_model from backend.data.db import prisma from backend.data.graph import Graph, Link, Node, create_graph @@ -466,7 +466,7 @@ class TestDataCreator: try: # Use the API function to create API key - api_key, _ = await generate_api_key( + api_key, _ = await create_api_key( name=faker.word(), user_id=user["id"], permissions=[ diff --git a/autogpt_platform/backend/test/test_data_creator.py b/autogpt_platform/backend/test/test_data_creator.py index 7901ac7574..4df237b510 100644 --- a/autogpt_platform/backend/test/test_data_creator.py +++ b/autogpt_platform/backend/test/test_data_creator.py @@ -21,6 +21,7 @@ import random from datetime import datetime import prisma.enums +from autogpt_libs.api_key.keysmith import APIKeySmith from faker import Faker from prisma import Json, Prisma from prisma.types import ( @@ -30,7 +31,6 @@ from prisma.types import ( AgentNodeLinkCreateInput, AnalyticsDetailsCreateInput, AnalyticsMetricsCreateInput, - APIKeyCreateInput, CreditTransactionCreateInput, IntegrationWebhookCreateInput, ProfileCreateInput, @@ -544,20 +544,22 @@ async def main(): # Insert APIKeys print(f"Inserting {NUM_USERS} api keys") for user in users: + api_key = APIKeySmith().generate_key() await db.apikey.create( - data=APIKeyCreateInput( - name=faker.word(), - prefix=str(faker.uuid4())[:8], - postfix=str(faker.uuid4())[-8:], - key=str(faker.sha256()), - status=prisma.enums.APIKeyStatus.ACTIVE, - permissions=[ + data={ + "name": faker.word(), + "head": api_key.head, + "tail": api_key.tail, + "hash": api_key.hash, + "salt": api_key.salt, + "status": prisma.enums.APIKeyStatus.ACTIVE, + "permissions": [ prisma.enums.APIKeyPermission.EXECUTE_GRAPH, prisma.enums.APIKeyPermission.READ_GRAPH, ], - description=faker.text(), - userId=user.id, - ) + "description": faker.text(), + "userId": user.id, + } ) # Refresh materialized views diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/APIKeySection.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/APIKeySection.tsx index 61a2dcf260..b5f519e5a5 100644 --- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/APIKeySection.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/APIKeySection.tsx @@ -48,7 +48,7 @@ export function APIKeysSection() { {key.name}
- {`${key.prefix}******************${key.postfix}`} + {`${key.head}******************${key.tail}`}
diff --git a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/useAPISection.tsx b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/useAPISection.tsx index 20930ed276..5fe691f025 100644 --- a/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/useAPISection.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/profile/(user)/api_keys/components/APIKeySection/useAPISection.tsx @@ -4,7 +4,6 @@ import { useDeleteV1RevokeApiKey, useGetV1ListUserApiKeys, } from "@/app/api/__generated__/endpoints/api-keys/api-keys"; -import { APIKeyWithoutHash } from "@/app/api/__generated__/models/aPIKeyWithoutHash"; import { useToast } from "@/components/molecules/Toast/use-toast"; import { getQueryClient } from "@/lib/react-query/queryClient"; @@ -15,9 +14,9 @@ export const useAPISection = () => { const { data: apiKeys, isLoading } = useGetV1ListUserApiKeys({ query: { select: (res) => { - return (res.data as APIKeyWithoutHash[]).filter( - (key) => key.status === "ACTIVE", - ); + if (res.status !== 200) return undefined; + + return res.data.filter((key) => key.status === "ACTIVE"); }, }, }); diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index f860d76e79..20ff01893e 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -2148,18 +2148,8 @@ "content": { "application/json": { "schema": { - "anyOf": [ - { - "items": { - "$ref": "#/components/schemas/APIKeyWithoutHash" - }, - "type": "array" - }, - { - "additionalProperties": { "type": "string" }, - "type": "object" - } - ], + "items": { "$ref": "#/components/schemas/APIKeyInfo" }, + "type": "array", "title": "Response Getv1List User Api Keys" } } @@ -2230,7 +2220,7 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/APIKeyWithoutHash" } + "schema": { "$ref": "#/components/schemas/APIKeyInfo" } } } }, @@ -2266,7 +2256,7 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/APIKeyWithoutHash" } + "schema": { "$ref": "#/components/schemas/APIKeyInfo" } } } }, @@ -2304,7 +2294,7 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/APIKeyWithoutHash" } + "schema": { "$ref": "#/components/schemas/APIKeyInfo" } } } }, @@ -2352,7 +2342,7 @@ "description": "Successful Response", "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/APIKeyWithoutHash" } + "schema": { "$ref": "#/components/schemas/APIKeyInfo" } } } }, @@ -4518,22 +4508,20 @@ "required": ["provider", "api_key"], "title": "APIKeyCredentials" }, - "APIKeyPermission": { - "type": "string", - "enum": ["EXECUTE_GRAPH", "READ_GRAPH", "EXECUTE_BLOCK", "READ_BLOCK"], - "title": "APIKeyPermission" - }, - "APIKeyStatus": { - "type": "string", - "enum": ["ACTIVE", "REVOKED", "SUSPENDED"], - "title": "APIKeyStatus" - }, - "APIKeyWithoutHash": { + "APIKeyInfo": { "properties": { "id": { "type": "string", "title": "Id" }, "name": { "type": "string", "title": "Name" }, - "prefix": { "type": "string", "title": "Prefix" }, - "postfix": { "type": "string", "title": "Postfix" }, + "head": { + "type": "string", + "title": "Head", + "description": "The first 8 characters of the key" + }, + "tail": { + "type": "string", + "title": "Tail", + "description": "The last 8 characters of the key" + }, "status": { "$ref": "#/components/schemas/APIKeyStatus" }, "permissions": { "items": { "$ref": "#/components/schemas/APIKeyPermission" }, @@ -4569,17 +4557,24 @@ "required": [ "id", "name", - "prefix", - "postfix", + "head", + "tail", "status", "permissions", "created_at", - "last_used_at", - "revoked_at", - "description", "user_id" ], - "title": "APIKeyWithoutHash" + "title": "APIKeyInfo" + }, + "APIKeyPermission": { + "type": "string", + "enum": ["EXECUTE_GRAPH", "READ_GRAPH", "EXECUTE_BLOCK", "READ_BLOCK"], + "title": "APIKeyPermission" + }, + "APIKeyStatus": { + "type": "string", + "enum": ["ACTIVE", "REVOKED", "SUSPENDED"], + "title": "APIKeyStatus" }, "AddUserCreditsResponse": { "properties": { @@ -5022,7 +5017,7 @@ }, "CreateAPIKeyResponse": { "properties": { - "api_key": { "$ref": "#/components/schemas/APIKeyWithoutHash" }, + "api_key": { "$ref": "#/components/schemas/APIKeyInfo" }, "plain_text_key": { "type": "string", "title": "Plain Text Key" } }, "type": "object",