Repository: QuivrHQ/quivr
Pull request: feat: user can create api keys
# Description

Please include a summary of the changes and the related issue. Please also include relevant motivation and context.

Create and use API keys
#315 

## Checklist before requesting a review

Please delete options that are not relevant.

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented hard-to-understand areas
- [ ] I have ideally added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged

## Screenshots (if appropriate):


Problem statement:
[API Key Gen] AAUser I can create an API key with the FE for Quivr
BE

1. Create an endpoint called key_routes.
2. This endpoint should have POST
3. Create new table in Supabase for keys.
4. Hit Supabase DB and add row to keys (user_id, key, date_created, date_deleted, isDeleted).
5. Only return key for that call.
6. If POST is called again, a new key is created.

Hints from issue discussion:
FE

1. In config page add a new child
2. in child add button for creating new key. 
3. the call should hit the endpoint and the key shown from BE

Commit message: fix: linting on build

Patch to review:
diff --git a/backend/auth/auth_bearer.py b/backend/auth/auth_bearer.py
index cc12c027c569..4a0f073c5fcc 100644
--- a/backend/auth/auth_bearer.py
+++ b/backend/auth/auth_bearer.py
@@ -1,33 +1,79 @@
-import os
 from typing import Optional
-
-from fastapi import HTTPException, Request, Depends
-from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from fastapi import Depends, Request, HTTPException
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+import os
 from models.users import User
-from .auth_handler import decode_access_token
-
+from utils.vectors import CommonsDep
+from asyncpg.exceptions import DataError
+from auth.auth_handler import decode_access_token
+from datetime import datetime
 
 class JWTBearer(HTTPBearer):
     def __init__(self, auto_error: bool = True):
         super().__init__(auto_error=auto_error)
 
-    async def __call__(self, request: Request):
+    async def __call__(self, request: Request, commons: CommonsDep):
         credentials: Optional[HTTPAuthorizationCredentials] = await super().__call__(request)
+        self.check_scheme(credentials)
+        token = credentials.credentials
+        return await self.authenticate(token, commons)
+
+    def check_scheme(self, credentials):
+        if credentials and not credentials.scheme == "Bearer":
+            raise HTTPException(status_code=402, detail="Invalid authorization scheme.")
+        elif not credentials:
+            raise HTTPException(status_code=403, detail="Invalid authorization code.")
+
+    async def authenticate(self, token, commons):
         if os.environ.get("AUTHENTICATE") == "false":
-            return True
-        if credentials:
-            if not credentials.scheme == "Bearer":
-                raise HTTPException(status_code=402, detail="Invalid authorization scheme.")
-            token = credentials.credentials
-            if not self.verify_jwt(token):
-                raise HTTPException(status_code=402, detail="Invalid token or expired token.")
-            return self.verify_jwt(token)  # change this line
+            return self.get_test_user()
+        elif self.verify_jwt(token):
+            return self.decode_jwt(token)
+        elif await self.verify_api_key(token, commons):
+            return await self.get_user_from_api_key(token, commons)
         else:
-            raise HTTPException(status_code=403, detail="Invalid authorization code.")
+            raise HTTPException(status_code=402, detail="Invalid token or expired token.")
+
+    def get_test_user(self):
+        return {'email': 'test@example.com'}  # replace with test user information
 
     def verify_jwt(self, jwtoken: str):
         payload = decode_access_token(jwtoken)
-        return payload
+        return payload is not None
+
+    def decode_jwt(self, jwtoken: str):
+        return decode_access_token(jwtoken)
+
+    async def verify_api_key(self, api_key: str, commons: CommonsDep):
+        try:
+            # Use UTC time to avoid timezone issues
+            current_date = datetime.utcnow().date()
+            result = commons['supabase'].table('api_keys').select('api_key', 'creation_time').filter('api_key', 'eq', api_key).filter('is_active', 'eq', True).execute()
+            if result.data is not None and len(result.data) > 0:
+                api_key_creation_date = datetime.strptime(result.data[0]['creation_time'], "%Y-%m-%dT%H:%M:%S").date()
+
+                # Check if the API key was created today: Todo remove this check and use deleted_time instead.
+                if api_key_creation_date == current_date:
+                    return True
+            return False
+        except DataError:
+            return False
+
+
+    async def get_user_from_api_key(self, api_key: str, commons: CommonsDep):
+    # Lookup the user_id from the api_keys table
+        user_id_data = commons['supabase'].table('api_keys').select('user_id').filter('api_key', 'eq', api_key).execute()
+    
+        if not user_id_data.data:
+            raise HTTPException(status_code=400, detail="Invalid API key.")
+    
+        user_id = user_id_data.data[0]['user_id']
+
+        # Lookup the email from the users table. Todo: remove and use user_id for credentials
+        user_email_data = commons['supabase'].table('users').select('email').filter('user_id', 'eq', user_id).execute()
+
+        return {'email': user_email_data.data[0]['email']} if user_email_data.data else {'email': None}
+
 
 
 def get_current_user(credentials: dict = Depends(JWTBearer())) -> User:
diff --git a/backend/main.py b/backend/main.py
index 3ef98ca1b4ef..b2b04f6b9263 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -10,6 +10,7 @@
 from routes.misc_routes import misc_router
 from routes.upload_routes import upload_router
 from routes.user_routes import user_router
+from routes.api_key_routes import api_key_router
 
 logger = get_logger(__name__)
 
@@ -29,3 +30,4 @@ async def startup_event():
 app.include_router(misc_router)
 app.include_router(upload_router)
 app.include_router(user_router)
+app.include_router(api_key_router)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 6566fef30f8d..78069e173c01 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -19,3 +19,4 @@ guidance==0.0.53
 python-jose==3.3.0
 google_cloud_aiplatform==1.25.0
 transformers==4.30.1
+asyncpg==0.27.0
diff --git a/backend/routes/api_key_routes.py b/backend/routes/api_key_routes.py
new file mode 100644
index 000000000000..0f6f7494045e
--- /dev/null
+++ b/backend/routes/api_key_routes.py
@@ -0,0 +1,82 @@
+from datetime import datetime
+import time
+from typing import List
+from pydantic import BaseModel
+from auth.auth_bearer import JWTBearer, get_current_user
+from fastapi import APIRouter, Depends
+from utils.vectors import fetch_user_id_from_credentials
+from models.users import User
+from utils.vectors import CommonsDep
+from uuid import uuid4
+from secrets import token_hex
+from asyncpg.exceptions import UniqueViolationError
+from logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class ApiKeyInfo(BaseModel):
+    key_id: str
+    creation_time: str
+
+class ApiKey(BaseModel):
+    api_key: str
+    
+
+api_key_router = APIRouter()
+
+
+@api_key_router.post("/api-key", response_model=ApiKey, dependencies=[Depends(JWTBearer())])
+async def create_api_key(commons: CommonsDep, current_user: User = Depends(get_current_user)):
+
+    date = time.strftime("%Y%m%d")
+    user_id = fetch_user_id_from_credentials(commons, date, {"email": current_user.email})
+
+    """Create new API key for current user."""
+
+    new_key_id = str(uuid4())
+    new_api_key = token_hex(16)
+    api_key_inserted = False
+
+    while not api_key_inserted:
+        try:
+            # Attempt to insert new API key into database
+            commons['supabase'].table('api_keys').insert([{
+                "key_id": new_key_id,
+                "user_id": user_id,
+                "api_key": new_api_key,
+                "creation_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
+                "is_active": True
+            }]).execute()
+
+            api_key_inserted = True
+
+        except UniqueViolationError:
+            # Generate a new API key if the current one is already in use
+            new_api_key = token_hex(16)
+
+    logger.info(f"Created new API key for user {current_user.email}.")
+
+    return {"api_key": new_api_key}
+
+
+@api_key_router.delete("/api-key/{key_id}", dependencies=[Depends(JWTBearer())])
+async def delete_api_key(key_id: str, commons: CommonsDep, current_user: User = Depends(get_current_user)):
+    """Delete (deactivate) an API key for current user."""
+
+    commons['supabase'].table('api_keys').update({
+        "is_active": False,
+        "deleted_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+    }).match({"key_id": key_id, "user_id": current_user.user_id}).execute()
+
+    return {"message": "API key deleted."}
+
+
+@api_key_router.get("/api-keys", response_model=List[ApiKeyInfo], dependencies=[Depends(JWTBearer())])
+async def get_api_keys(commons: CommonsDep, current_user: User = Depends(get_current_user)):
+    """Get all active API keys for current user."""
+
+    user_id = fetch_user_id_from_credentials(commons, time.strftime("%Y%m%d"), {"email": current_user.email})
+
+    response = commons['supabase'].table('api_keys').select("key_id, creation_time").filter('user_id', 'eq', user_id).filter('is_active', 'eq', True).execute()
+    return response.data
diff --git a/docs/docs/backend/api.md b/docs/docs/backend/api.md
new file mode 100644
index 000000000000..73c74d69ccbb
--- /dev/null
+++ b/docs/docs/backend/api.md
@@ -0,0 +1,37 @@
+---
+sidebar_position: 2
+---
+
+# API Backend Documentation
+
+## Overview
+This documentation outlines the key points and usage instructions for interacting with the API backend. Please follow the guidelines below to use the backend services effectively.
+
+## Usage Instructions
+
+1. Standalone Backend
+   - The backend can now be used independently without the frontend application.
+   - Users can interact with the API endpoints directly using API testing tools like Postman.
+
+2. Generating API Key
+   - To access the backend services, you need to sign in to the frontend application.
+   - Once signed in, navigate to the `/config` page to generate a new API key.
+   - The API key will be required to authenticate your requests to the backend.
+
+3. Authenticating Requests
+   - When making requests to the backend API, include the following header:
+     - `Authorization: Bearer {api_key}`
+     - Replace `{api_key}` with the generated API key obtained from the frontend.
+
+4. Future Plans
+   - The development team has plans to introduce additional features and improvements.
+   - These include the ability to delete API keys and view the list of active keys.
+   - The GitHub roadmap will provide more details on upcoming features, including addressing active issues.
+
+5. API Key Expiration
+   - Each API key has a daily expiration.
+   - The expiration is based on Coordinated Universal Time (UTC) to avoid timezone issues.
+   - After the expiration time, typically at midnight UTC, you will need to regenerate the API key to continue using the backend services.
+   - Once the capability to delete keys is implemented, you will have the option to delete keys manually.
+
+Please refer to the official GitHub repository and the project roadmap for more information and updates on the backend services.
diff --git a/frontend/app/config/components/ApiKeyConfig.tsx b/frontend/app/config/components/ApiKeyConfig.tsx
new file mode 100644
index 000000000000..9a11f958e7df
--- /dev/null
+++ b/frontend/app/config/components/ApiKeyConfig.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import Button from "@/lib/components/ui/Button";
+import { useAxios } from "@/lib/useAxios";
+import { useState } from "react";
+
+export const ApiKeyConfig = (): JSX.Element => {
+  const [apiKey, setApiKey] = useState("");
+  const { axiosInstance } = useAxios();
+
+  const handleCreateClick = async () => {
+    try {
+      const response = await axiosInstance.post("/api-key"); // replace with your api-key endpoint URL
+      setApiKey(response.data.api_key);
+    } catch (error) {
+      console.error("Error creating API key: ", error);
+    }
+  };
+
+  const copyToClipboard = async (text: string) => {
+    try {
+      await navigator.clipboard.writeText(text);
+    } catch (err) {
+      console.error("Failed to copy:", err);
+    }
+  };
+
+  const handleCopyClick = () => {
+    if (apiKey) {
+      copyToClipboard(apiKey);
+    }
+  };
+
+  return (
+    <>
+      <div className="border-b border-gray-300 w-full max-w-xl mb-8">
+        <p className="text-center text-gray-600 uppercase tracking-wide font-semibold">
+          API Key Config
+        </p>
+      </div>
+      <div className="flex justify-between items-center">
+        <div className="flex items-center space-x-4">
+          {!apiKey && (
+            <Button variant="secondary" onClick={handleCreateClick}>
+              Create New Key
+            </Button>
+          )}
+        </div>
+        {apiKey && (
+          <div className="flex items-center space-x-4">
+            <span className="text-gray-600">{apiKey}</span>
+            <Button variant="secondary" onClick={handleCopyClick}>
+              📋
+            </Button>
+          </div>
+        )}
+      </div>
+    </>
+  );
+};
diff --git a/frontend/app/config/page.tsx b/frontend/app/config/page.tsx
index 3a2933ee6f33..3e791ac114c4 100644
--- a/frontend/app/config/page.tsx
+++ b/frontend/app/config/page.tsx
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
 
 import { useSupabase } from "../supabase-provider";
 import { ConfigForm, ConfigTitle } from "./components";
+import { ApiKeyConfig } from "./components/ApiKeyConfig";
 
 // TODO: Use states instead of NEXTJS router to open and close modal
 export default function ConfigPage() {
@@ -17,6 +18,7 @@ export default function ConfigPage() {
       <section className="w-full outline-none pt-10 flex flex-col gap-5 items-center justify-center p-6">
         <ConfigTitle />
         <ConfigForm />
+        <ApiKeyConfig />
       </section>
     </main>
   );
diff --git a/frontend/lib/context/ChatsProvider/hooks/useChats.ts b/frontend/lib/context/ChatsProvider/hooks/useChats.ts
index 3b00c8af420d..cbd65f784903 100644
--- a/frontend/lib/context/ChatsProvider/hooks/useChats.ts
+++ b/frontend/lib/context/ChatsProvider/hooks/useChats.ts
@@ -34,24 +34,27 @@ export default function useChats() {
         text: "Error occured while fetching your chats",
       });
     }
-  }, []);
-
-  const fetchChat = useCallback(async (chatId?: UUID) => {
-    if (!chatId) return;
-    try {
-      console.log(`Fetching chat ${chatId}`);
-      const response = await axiosInstance.get<Chat>(`/chat/${chatId}`);
-      console.log(response.data);
-
-      setChat(response.data);
-    } catch (error) {
-      console.error(error);
-      publish({
-        variant: "danger",
-        text: `Error occured while fetching ${chatId}`,
-      });
-    }
-  }, []);
+  }, [axiosInstance, publish]);
+
+  const fetchChat = useCallback(
+    async (chatId?: UUID) => {
+      if (!chatId) return;
+      try {
+        console.log(`Fetching chat ${chatId}`);
+        const response = await axiosInstance.get<Chat>(`/chat/${chatId}`);
+        console.log(response.data);
+
+        setChat(response.data);
+      } catch (error) {
+        console.error(error);
+        publish({
+          variant: "danger",
+          text: `Error occured while fetching ${chatId}`,
+        });
+      }
+    },
+    [axiosInstance, publish]
+  );
 
   type ChatResponse = Omit<Chat, "chatId"> & { chatId: UUID | undefined };
 
diff --git a/scripts/tables.sql b/scripts/tables.sql
index 615423a65531..1369e50a9e56 100644
--- a/scripts/tables.sql
+++ b/scripts/tables.sql
@@ -103,3 +103,14 @@ BEGIN
     LIMIT match_count;
 END;
 $$;
+
+-- Create api_keys table
+CREATE TABLE IF NOT EXISTS api_keys(
+    key_id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+    user_id UUID REFERENCES users(user_id),
+    api_key TEXT UNIQUE,
+    creation_time TIMESTAMP DEFAULT current_timestamp,
+    deleted_time TIMESTAMP,
+    is_active BOOLEAN DEFAULT true
+);
+


Code review comments:
diff_hunk: @@ -0,0 +1,82 @@
+from datetime import datetime
+import time
+from typing import List
+from pydantic import BaseModel
+from auth.auth_bearer import JWTBearer, get_current_user
+from fastapi import APIRouter, Depends
+from utils.vectors import fetch_user_id_from_credentials
+from models.users import User
+from utils.vectors import CommonsDep
+from uuid import uuid4
+from secrets import token_hex
+from asyncpg.exceptions import UniqueViolationError
+from logger import get_logger
+
+logger = get_logger(__name__)
+
+
+class ApiKeyInfo(BaseModel):
+    key_id: str
+    creation_time: str
+
+class ApiKey(BaseModel):
+    api_key: str
+    
+
+api_key_router = APIRouter()
+
+
+@api_key_router.post("/api-key", response_model=ApiKey, dependencies=[Depends(JWTBearer())])
+async def create_api_key(commons: CommonsDep, current_user: User = Depends(get_current_user)):
+
+    date = time.strftime("%Y%m%d")
+    user_id = fetch_user_id_from_credentials(commons, date, {"email": current_user.email})
+
+    """Create new API key for current user."""
+
+    new_key_id = str(uuid4())
+    new_api_key = token_hex(16)
+    api_key_inserted = False
+
+    while not api_key_inserted:
+        try:
+            # Attempt to insert new API key into database
+            commons['supabase'].table('api_keys').insert([{
+                "key_id": new_key_id,
+                "user_id": user_id,
+                "api_key": new_api_key,
+                "creation_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
+                "is_active": True
+            }]).execute()
+
+            api_key_inserted = True
+
+        except UniqueViolationError:
+            # Generate a new API key if the current one is already in use
+            new_api_key = token_hex(16)
+
+    logger.info(f"Created new API key for user {current_user.email}.")
+
+    return {"api_key": new_api_key}
+
+
+@api_key_router.delete("/api-key/{key_id}", dependencies=[Depends(JWTBearer())])
+async def delete_api_key(key_id: str, commons: CommonsDep, current_user: User = Depends(get_current_user)):
+    """Delete (deactivate) an API key for current user."""
+
+    commons['supabase'].table('api_keys').update({
+        "is_active": False,
+        "deleted_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+    }).match({"key_id": key_id, "user_id": current_user.user_id}).execute()
+
+    return {"message": "API key deleted."}
+
+
+@api_key_router.get("/api-keys", response_model=List[ApiKeyInfo], dependencies=[Depends(JWTBearer())])
+async def get_api_keys(commons: CommonsDep, current_user: User = Depends(get_current_user)):
+    """Get all active API keys for current user."""
path: backend/routes/api_key_routes.py
text: @author:
```suggestion
    """Get all active API keys for the current user. Return only the key_id and the creation_time."""
```