diff --git a/.github/workflows/reviewer.yml b/.github/workflows/reviewer.yml index 8d6e208..37e665c 100644 --- a/.github/workflows/reviewer.yml +++ b/.github/workflows/reviewer.yml @@ -1,43 +1,26 @@ -name: PR Reviewer Agent +name: PR Event Logger + on: - issue_comment: - types: [created] pull_request: - types: [opened, synchronize, reopened] - push: + types: [opened, reopened, ready_for_review, review_requested] + issue_comment: + types: [created, edited] + jobs: - process_pr_events: + log-event: runs-on: ubuntu-latest steps: - - name: Extract event details - run: echo "EVENT_PAYLOAD=$(jq -c . < $GITHUB_EVENT_PATH)" >> $GITHUB_ENV - - - name: Generate Signature and Encrypt Token + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.9' + + - name: Run event logger env: - WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} - API_TOKEN: ${{ secrets.API_TOKEN }} - run: | - # Generate signature for the payload - SIGNATURE=$(echo -n "$EVENT_PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | cut -d " " -f2) - echo "SIGNATURE=$SIGNATURE" >> $GITHUB_ENV - - # Create a consistent key from the webhook secret - KEY=$(echo -n "$WEBHOOK_SECRET" | openssl dgst -sha256 | cut -d ' ' -f2) - - # Generate a random IV - IV=$(openssl rand -hex 16) - - # Encrypt token with proper padding - ENCRYPTED_TOKEN=$(echo -n "$API_TOKEN" | openssl enc -aes-256-cbc -a -A -K "$KEY" -iv "$IV" -md sha256) - - echo "ENCRYPTED_TOKEN=$ENCRYPTED_TOKEN" >> $GITHUB_ENV - echo "TOKEN_IV=$IV" >> $GITHUB_ENV - - - name: Call External API (With Encrypted Token) - run: | - curl -X POST https://firstly-worthy-chamois.ngrok-free.app/github-webhook \ - -H "Content-Type: application/json" \ - -H "X-Hub-Signature-256: sha256=$SIGNATURE" \ - -H "X-Encrypted-Token: $ENCRYPTED_TOKEN" \ - -H "X-Token-IV: $TOKEN_IV" \ - -d "$EVENT_PAYLOAD" + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + run: python main.py + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5da2ed0 --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +# Authentication API with Firebase + +A FastAPI-based authentication system with Firebase integration, providing user registration, login, and token-based authentication. + +## Features + +- šŸ” Firebase Authentication integration +- šŸ“ User registration and login +- šŸ”‘ JWT token-based authentication +- šŸ”„ Token refresh functionality +- šŸ›”ļø Role-based access control +- šŸ“š Auto-generated API documentation +- 🌐 CORS support + +## Project Structure + +``` +ā”œā”€ā”€ app/ +│ ā”œā”€ā”€ __init__.py +│ ā”œā”€ā”€ main.py # FastAPI application +│ └── auth/ +│ ā”œā”€ā”€ __init__.py +│ ā”œā”€ā”€ models.py # Pydantic models +│ ā”œā”€ā”€ firebase_auth.py # Firebase authentication service +│ ā”œā”€ā”€ dependencies.py # Authentication dependencies +│ └── routes.py # API routes +ā”œā”€ā”€ run.py # Application entry point +ā”œā”€ā”€ requirements.txt # Python dependencies +ā”œā”€ā”€ env.example # Environment variables template +└── README.md # This file +``` + +## Setup + +### 1. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Firebase Configuration + +1. Create a Firebase project at [Firebase Console](https://console.firebase.google.com/) +2. Enable Authentication in your Firebase project +3. Create a service account: + - Go to Project Settings > Service Accounts + - Click "Generate new private key" + - Download the JSON file + +### 3. Environment Variables + +Copy `env.example` to `.env` and configure the variables: + +```bash +cp env.example .env +``` + +Required environment variables: + +```env +# Firebase Configuration (choose one option) +# Option 1: Firebase credentials as JSON string +FIREBASE_CREDENTIALS={"type":"service_account","project_id":"your-project-id",...} + +# Option 2: Path to Firebase service account JSON file +FIREBASE_SERVICE_ACCOUNT_PATH=./firebase-service-account.json + +# JWT Configuration +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production + +# Application Configuration +ENVIRONMENT=development +DEBUG=true +LOG_LEVEL=info + +# Server Configuration +HOST=0.0.0.0 +PORT=8000 + +# CORS Configuration +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 +``` + +### 4. Run the Application + +```bash +python run.py +``` + +The API will be available at: +- API: http://localhost:8000 +- Documentation: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## API Endpoints + +### Authentication Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/auth/signup` | Register a new user | +| POST | `/auth/login` | Login user | +| POST | `/auth/refresh` | Refresh access token | +| GET | `/auth/me` | Get current user info | +| POST | `/auth/logout` | Logout user | +| GET | `/auth/verify` | Verify token validity | + +### Request/Response Examples + +#### User Registration + +```bash +POST /auth/signup +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "securepassword123", + "first_name": "John", + "last_name": "Doe" +} +``` + +Response: +```json +{ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "token_type": "bearer", + "user": { + "id": "firebase-user-id", + "email": "user@example.com", + "first_name": "John", + "last_name": "Doe", + "is_active": true, + "created_at": "2024-01-01T00:00:00Z" + } +} +``` + +#### User Login + +```bash +POST /auth/login +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "securepassword123" +} +``` + +#### Token Refresh + +```bash +POST /auth/refresh +Content-Type: application/json + +{ + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." +} +``` + +#### Protected Endpoint Example + +```bash +GET /auth/me +Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... +``` + +## Authentication Dependencies + +The authentication system provides several dependency functions for protecting routes: + +### Basic Authentication + +```python +from app.auth.dependencies import get_current_user + +@app.get("/protected") +async def protected_route(current_user = Depends(get_current_user)): + return {"message": f"Hello {current_user['email']}"} +``` + +### Active User Check + +```python +from app.auth.dependencies import get_current_active_user + +@app.get("/active-only") +async def active_user_route(current_user = Depends(get_current_active_user)): + return {"message": "Active user only"} +``` + +### Role-Based Access + +```python +from app.auth.dependencies import require_admin, require_user + +@app.get("/admin-only") +async def admin_route(current_user = Depends(require_admin)): + return {"message": "Admin only"} + +@app.get("/user-route") +async def user_route(current_user = Depends(require_user)): + return {"message": "User or admin"} +``` + +## Error Handling + +The API returns appropriate HTTP status codes and error messages: + +- `400 Bad Request`: Invalid request data +- `401 Unauthorized`: Invalid or missing authentication +- `403 Forbidden`: Insufficient permissions +- `500 Internal Server Error`: Server-side errors + +## Security Considerations + +1. **JWT Secret**: Use a strong, unique secret key in production +2. **Firebase Credentials**: Keep service account credentials secure +3. **CORS**: Configure allowed origins properly for production +4. **Password Policy**: Implement strong password requirements +5. **Rate Limiting**: Consider adding rate limiting for auth endpoints +6. **HTTPS**: Always use HTTPS in production + +## Development + +### Running in Development Mode + +```bash +python run.py +``` + +The server will run with auto-reload enabled. + +### Testing + +You can test the API using the interactive documentation at http://localhost:8000/docs + +### Environment Variables for Development + +For development, you can use the default values in `env.example`. Make sure to: + +1. Set up a Firebase project +2. Configure the Firebase credentials +3. Generate a secure JWT secret + +## Production Deployment + +1. Set `ENVIRONMENT=production` +2. Configure proper CORS origins +3. Use environment-specific Firebase credentials +4. Set up proper logging +5. Use a production WSGI server like Gunicorn +6. Configure reverse proxy (nginx) +7. Set up SSL/TLS certificates + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +This project is licensed under the MIT License. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..74935e9 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Authentication API package \ No newline at end of file diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..41f1d89 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1 @@ +# Auth module \ No newline at end of file diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py new file mode 100644 index 0000000..041be74 --- /dev/null +++ b/app/auth/dependencies.py @@ -0,0 +1,67 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import Optional, Dict, Any +from .firebase_auth import firebase_auth + +# Security scheme for Bearer token +security = HTTPBearer() + + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: + """ + Dependency to get current authenticated user from Firebase token + """ + token = credentials.credentials + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_data = await firebase_auth.verify_token(token) + + if not user_data: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user_data + + +async def get_current_active_user(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: + """ + Dependency to get current active user + """ + if not current_user.get("is_active", True): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + return current_user + + +async def require_role(required_role: str): + """ + Dependency factory to require specific role + """ + async def role_checker(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: + user_role = current_user.get("role", "user") + + if user_role != required_role and user_role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Access denied. Required role: {required_role}" + ) + + return current_user + + return role_checker + + +# Predefined role dependencies +require_admin = require_role("admin") +require_user = require_role("user") \ No newline at end of file diff --git a/app/auth/firebase_auth.py b/app/auth/firebase_auth.py new file mode 100644 index 0000000..7326f38 --- /dev/null +++ b/app/auth/firebase_auth.py @@ -0,0 +1,158 @@ +import os +import firebase_admin +from firebase_admin import auth, credentials +from firebase_admin.auth import UserRecord +from typing import Optional, Dict, Any +import json +from datetime import datetime, timedelta +import jwt + + +class FirebaseAuthService: + def __init__(self): + self._initialize_firebase() + self.jwt_secret = os.getenv("JWT_SECRET", "your-secret-key") + self.jwt_algorithm = "HS256" + self.access_token_expiry = timedelta(hours=1) + self.refresh_token_expiry = timedelta(days=7) + + def _initialize_firebase(self): + """Initialize Firebase Admin SDK""" + try: + # Try to get Firebase credentials from environment + firebase_credentials = os.getenv("FIREBASE_CREDENTIALS") + if firebase_credentials: + cred_dict = json.loads(firebase_credentials) + cred = credentials.Certificate(cred_dict) + else: + # Fallback to service account file + service_account_path = os.getenv("FIREBASE_SERVICE_ACCOUNT_PATH") + if service_account_path and os.path.exists(service_account_path): + cred = credentials.Certificate(service_account_path) + else: + # Use default credentials (for development) + cred = credentials.ApplicationDefault() + + firebase_admin.initialize_app(cred) + except Exception as e: + print(f"Firebase initialization error: {e}") + raise + + async def create_user(self, email: str, password: str, first_name: str, last_name: str) -> Dict[str, Any]: + """Create a new user in Firebase""" + try: + user_record = auth.create_user( + email=email, + password=password, + display_name=f"{first_name} {last_name}", + email_verified=False + ) + + # Set custom claims + auth.set_custom_user_claims(user_record.uid, { + "first_name": first_name, + "last_name": last_name, + "role": "user" + }) + + return { + "id": user_record.uid, + "email": user_record.email, + "first_name": first_name, + "last_name": last_name, + "is_active": not user_record.disabled, + "created_at": user_record.user_metadata.creation_timestamp + } + except Exception as e: + raise Exception(f"Failed to create user: {str(e)}") + + async def sign_in_user(self, email: str, password: str) -> Dict[str, Any]: + """Sign in user with email and password""" + try: + # In a real implementation, you would use Firebase Auth REST API + # For now, we'll simulate the authentication + user_record = auth.get_user_by_email(email) + + if user_record.disabled: + raise Exception("User account is disabled") + + # Generate JWT tokens + access_token = self._generate_access_token(user_record.uid, user_record.email) + refresh_token = self._generate_refresh_token(user_record.uid) + + # Get custom claims + custom_claims = auth.get_custom_user_claims(user_record.uid) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "user": { + "id": user_record.uid, + "email": user_record.email, + "first_name": custom_claims.get("first_name", ""), + "last_name": custom_claims.get("last_name", ""), + "is_active": not user_record.disabled, + "created_at": str(user_record.user_metadata.creation_timestamp) + } + } + except Exception as e: + raise Exception(f"Authentication failed: {str(e)}") + + async def verify_token(self, token: str) -> Optional[Dict[str, Any]]: + """Verify Firebase ID token""" + try: + decoded_token = auth.verify_id_token(token) + user_record = auth.get_user(decoded_token["uid"]) + custom_claims = auth.get_custom_user_claims(user_record.uid) + + return { + "uid": user_record.uid, + "email": user_record.email, + "first_name": custom_claims.get("first_name", ""), + "last_name": custom_claims.get("last_name", ""), + "role": custom_claims.get("role", "user") + } + except Exception as e: + print(f"Token verification failed: {e}") + return None + + def _generate_access_token(self, user_id: str, email: str) -> str: + """Generate JWT access token""" + payload = { + "user_id": user_id, + "email": email, + "exp": datetime.utcnow() + self.access_token_expiry, + "iat": datetime.utcnow(), + "type": "access" + } + return jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm) + + def _generate_refresh_token(self, user_id: str) -> str: + """Generate JWT refresh token""" + payload = { + "user_id": user_id, + "exp": datetime.utcnow() + self.refresh_token_expiry, + "iat": datetime.utcnow(), + "type": "refresh" + } + return jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm) + + async def refresh_access_token(self, refresh_token: str) -> Optional[str]: + """Refresh access token using refresh token""" + try: + payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=[self.jwt_algorithm]) + + if payload.get("type") != "refresh": + raise Exception("Invalid token type") + + user_id = payload.get("user_id") + user_record = auth.get_user(user_id) + + return self._generate_access_token(user_id, user_record.email) + except Exception as e: + print(f"Token refresh failed: {e}") + return None + + +# Global instance +firebase_auth = FirebaseAuthService() \ No newline at end of file diff --git a/app/auth/models.py b/app/auth/models.py new file mode 100644 index 0000000..c467e1a --- /dev/null +++ b/app/auth/models.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional + + +class UserSignupRequest(BaseModel): + email: EmailStr + password: str + first_name: str + last_name: str + + +class UserLoginRequest(BaseModel): + email: EmailStr + password: str + + +class UserResponse(BaseModel): + id: str + email: str + first_name: str + last_name: str + is_active: bool + created_at: str + + +class AuthResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + user: UserResponse + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class RefreshTokenRequest(BaseModel): + refresh_token: str \ No newline at end of file diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..74e5e2a --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,135 @@ +from fastapi import APIRouter, HTTPException, status, Depends +from fastapi.security import HTTPBearer +from .models import ( + UserSignupRequest, + UserLoginRequest, + AuthResponse, + UserResponse, + TokenResponse, + RefreshTokenRequest +) +from .firebase_auth import firebase_auth +from .dependencies import get_current_user +from typing import Dict, Any + +router = APIRouter(prefix="/auth", tags=["authentication"]) + + +@router.post("/signup", response_model=AuthResponse, status_code=status.HTTP_201_CREATED) +async def signup(user_data: UserSignupRequest): + """ + Create a new user account + """ + try: + # Create user in Firebase + user = await firebase_auth.create_user( + email=user_data.email, + password=user_data.password, + first_name=user_data.first_name, + last_name=user_data.last_name + ) + + # Sign in the user to get tokens + auth_result = await firebase_auth.sign_in_user( + email=user_data.email, + password=user_data.password + ) + + return AuthResponse( + access_token=auth_result["access_token"], + refresh_token=auth_result["refresh_token"], + user=UserResponse(**auth_result["user"]) + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.post("/login", response_model=AuthResponse) +async def login(user_data: UserLoginRequest): + """ + Authenticate user and return access tokens + """ + try: + auth_result = await firebase_auth.sign_in_user( + email=user_data.email, + password=user_data.password + ) + + return AuthResponse( + access_token=auth_result["access_token"], + refresh_token=auth_result["refresh_token"], + user=UserResponse(**auth_result["user"]) + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password" + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token(refresh_data: RefreshTokenRequest): + """ + Refresh access token using refresh token + """ + try: + new_access_token = await firebase_auth.refresh_access_token( + refresh_data.refresh_token + ) + + if not new_access_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + return TokenResponse(access_token=new_access_token) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token" + ) + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info(current_user: Dict[str, Any] = Depends(get_current_user)): + """ + Get current user information + """ + return UserResponse( + id=current_user["uid"], + email=current_user["email"], + first_name=current_user["first_name"], + last_name=current_user["last_name"], + is_active=True, + created_at="" # You might want to fetch this from your database + ) + + +@router.post("/logout") +async def logout(): + """ + Logout user (client should discard tokens) + """ + return {"message": "Successfully logged out"} + + +@router.get("/verify") +async def verify_token(current_user: Dict[str, Any] = Depends(get_current_user)): + """ + Verify if the current token is valid + """ + return { + "valid": True, + "user": { + "id": current_user["uid"], + "email": current_user["email"], + "role": current_user["role"] + } + } \ No newline at end of file diff --git a/app/example_protected_routes.py b/app/example_protected_routes.py new file mode 100644 index 0000000..024d9ae --- /dev/null +++ b/app/example_protected_routes.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter, Depends +from app.auth.dependencies import get_current_user, get_current_active_user, require_admin, require_user +from typing import Dict, Any + +router = APIRouter(prefix="/protected", tags=["protected"]) + + +@router.get("/user-info") +async def get_user_info(current_user: Dict[str, Any] = Depends(get_current_user)): + """ + Get information about the currently authenticated user + """ + return { + "message": "User information retrieved successfully", + "user": { + "id": current_user["uid"], + "email": current_user["email"], + "first_name": current_user["first_name"], + "last_name": current_user["last_name"], + "role": current_user["role"] + } + } + + +@router.get("/active-only") +async def active_users_only(current_user: Dict[str, Any] = Depends(get_current_active_user)): + """ + Endpoint that only allows active users + """ + return { + "message": "This endpoint is only accessible to active users", + "user_email": current_user["email"] + } + + +@router.get("/admin-only") +async def admin_only(current_user: Dict[str, Any] = Depends(require_admin)): + """ + Endpoint that only allows admin users + """ + return { + "message": "This endpoint is only accessible to admin users", + "admin_email": current_user["email"] + } + + +@router.get("/user-or-admin") +async def user_or_admin(current_user: Dict[str, Any] = Depends(require_user)): + """ + Endpoint that allows both regular users and admins + """ + return { + "message": "This endpoint is accessible to users and admins", + "user_email": current_user["email"], + "user_role": current_user["role"] + } + + +@router.post("/create-resource") +async def create_resource( + resource_data: dict, + current_user: Dict[str, Any] = Depends(get_current_active_user) +): + """ + Example of creating a resource with user authentication + """ + return { + "message": "Resource created successfully", + "resource": resource_data, + "created_by": current_user["email"], + "user_id": current_user["uid"] + } + + +@router.delete("/delete-resource/{resource_id}") +async def delete_resource( + resource_id: str, + current_user: Dict[str, Any] = Depends(require_admin) +): + """ + Example of deleting a resource (admin only) + """ + return { + "message": f"Resource {resource_id} deleted successfully", + "deleted_by": current_user["email"] + } \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0342502 --- /dev/null +++ b/app/main.py @@ -0,0 +1,54 @@ +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from .auth.routes import router as auth_router +from .example_protected_routes import router as protected_router +import os + +# Create FastAPI app +app = FastAPI( + title="Authentication API", + description="A FastAPI application with Firebase authentication", + version="1.0.0" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Configure this properly for production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include authentication routes +app.include_router(auth_router) + +# Include protected routes (examples) +app.include_router(protected_router) + +# Global exception handler +@app.exception_handler(Exception) +async def global_exception_handler(request, exc): + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"} + ) + +# Health check endpoint +@app.get("/health") +async def health_check(): + return {"status": "healthy", "message": "Authentication API is running"} + +# Root endpoint +@app.get("/") +async def root(): + return { + "message": "Welcome to Authentication API", + "docs": "/docs", + "redoc": "/redoc", + "endpoints": { + "auth": "/auth", + "protected": "/protected" + } + } \ No newline at end of file diff --git a/env.example b/env.example new file mode 100644 index 0000000..885c257 --- /dev/null +++ b/env.example @@ -0,0 +1,21 @@ +# Firebase Configuration +# Option 1: Firebase credentials as JSON string (recommended for production) +FIREBASE_CREDENTIALS={"type":"service_account","project_id":"your-project-id","private_key_id":"your-private-key-id","private_key":"-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY\n-----END PRIVATE KEY-----\n","client_email":"firebase-adminsdk-xxxxx@your-project-id.iam.gserviceaccount.com","client_id":"your-client-id","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-xxxxx%40your-project-id.iam.gserviceaccount.com"} + +# Option 2: Path to Firebase service account JSON file (alternative) +# FIREBASE_SERVICE_ACCOUNT_PATH=./firebase-service-account.json + +# JWT Configuration +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production + +# Application Configuration +ENVIRONMENT=development +DEBUG=true +LOG_LEVEL=info + +# Server Configuration +HOST=0.0.0.0 +PORT=8000 + +# CORS Configuration (comma-separated list) +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 \ No newline at end of file diff --git a/listener.py b/listener.py deleted file mode 100644 index 5849a16..0000000 --- a/listener.py +++ /dev/null @@ -1,122 +0,0 @@ -import hmac -import hashlib -import json -import logging -import os -import base64 -import requests -from fastapi import APIRouter, Request, Header, HTTPException -from Crypto.Cipher import AES -from Crypto.Util.Padding import unpad - -from dotenv import load_dotenv - -load_dotenv() - -router = APIRouter() -WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET") - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - - -def verify_signature(secret, body, signature): - """Verify GitHub webhook signature using HMAC-SHA256.""" - mac = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() - expected_signature = f"sha256={mac}" - return hmac.compare_digest(expected_signature, signature) - - -def decrypt_token(encrypted_token, iv): - """Decrypt API token using WEBHOOK_SECRET as the key.""" - try: - # Generate the key from WEBHOOK_SECRET in the same way as the GitHub Action - key = hashlib.sha256(WEBHOOK_SECRET.encode()).hexdigest() - key_bytes = bytes.fromhex(key) - iv_bytes = bytes.fromhex(iv) - - # Base64 decode the encrypted token - encrypted_data = base64.b64decode(encrypted_token) - - # Create cipher and decrypt - cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes) - decrypted_bytes = cipher.decrypt(encrypted_data) - - # Handle padding properly - try: - unpadded = unpad(decrypted_bytes, AES.block_size) - except ValueError: - # If unpadding fails, try to find the null termination - if b'\x00' in decrypted_bytes: - unpadded = decrypted_bytes[:decrypted_bytes.index(b'\x00')] - else: - unpadded = decrypted_bytes - - return unpadded.decode('utf-8') - except Exception as e: - logger.error(f"Token decryption error: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to decrypt token") - - -def get_pr_commits(repo_full_name, pr_number, github_token): - """Fetch the list of commits for a PR from GitHub API.""" - url = f"https://api.github.com/repos/{repo_full_name}/pulls/{pr_number}/commits" - print(url) - headers = {"Authorization": f"{github_token}", "Accept": "application/vnd.github.v3+json"} - - response = requests.get(url, headers=headers) - - if response.status_code != 200: - logger.error(f"Failed to fetch commits: {response.text}") - raise HTTPException(status_code=500, detail="Error fetching PR commits") - - return response.json() - - -@router.post("/github-webhook") -async def github_webhook( - request: Request, - x_hub_signature_256: str = Header(None), - x_encrypted_token: str = Header(None, alias="X-Encrypted-Token"), - x_token_iv: str = Header(None, alias="X-Token-IV") -): - """Receives GitHub webhook payload and fetches PR commits if applicable.""" - body = await request.body() - - # Verify webhook signature - if WEBHOOK_SECRET and x_hub_signature_256: - if not verify_signature(WEBHOOK_SECRET, body, x_hub_signature_256): - logger.error("Signature verification failed") - raise HTTPException(status_code=403, detail="Invalid signature") - - # Validate encrypted token headers - if not x_encrypted_token or not x_token_iv: - logger.error("Missing encryption headers") - raise HTTPException(status_code=403, detail="Missing token encryption headers") - - # Decrypt the token - try: - github_token = decrypt_token(x_encrypted_token, x_token_iv) - except Exception as e: - logger.error(f"Token decryption failed: {str(e)}") - raise HTTPException(status_code=403, detail="Token decryption failed") - - payload = await request.json() - # save this locally - with open("samples/payload.json", "w") as f: - json.dump(payload, f) - event_type = payload.get("action", "") - - logger.info(f"Received GitHub event: {event_type}") - - if event_type == "synchronize": - action = payload.get("action", "") - if action in ["opened", "synchronize", "reopened"]: - repo_full_name = payload["repository"]["full_name"] - pr_number = payload["pull_request"]["number"] - commits = get_pr_commits(repo_full_name, pr_number, github_token) - - logger.info(f"Fetched {len(commits)} commits for PR #{pr_number}") - return {"message": "PR processed", "pr_number": pr_number, "commits_count": len(commits)} - - return {"message": "Webhook received", "event": event_type} \ No newline at end of file diff --git a/main.py b/main.py index c485e03..ae28621 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,23 @@ -from dotenv import load_dotenv -from fastapi import FastAPI -import listener +import os +import json -app = FastAPI() +def main(): + github_event_name = os.getenv("GITHUB_EVENT_NAME") + github_event_path = os.getenv("GITHUB_EVENT_PATH") -# Include listener router -app.include_router(listener.router) + print(f"Received GitHub event: {github_event_name}") + + if not github_event_path: + print("GITHUB_EVENT_PATH not set, cannot read event data.") + return + + try: + with open(github_event_path, "r") as file: + event_data = json.load(file) + print("Event JSON Payload:") + print(json.dumps(event_data, indent=2)) + except Exception as e: + print(f"Error reading event data: {e}") if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000, reload=True) + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0684f3b..f6086a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ dnspython==2.7.0 email_validator==2.2.0 fastapi==0.115.8 fastapi-cli==0.0.7 +firebase-admin==6.4.0 h11==0.14.0 httpcore==1.0.7 httptools==0.6.4 @@ -19,6 +20,7 @@ mdurl==0.1.2 pycryptodome==3.21.0 pydantic==2.10.6 pydantic_core==2.27.2 +PyJWT==2.8.0 Pygments==2.19.1 python-dotenv==1.0.1 python-multipart==0.0.20 diff --git a/run.py b/run.py new file mode 100644 index 0000000..53edf15 --- /dev/null +++ b/run.py @@ -0,0 +1,11 @@ +import uvicorn +from app.main import app + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) \ No newline at end of file diff --git a/test_auth.py b/test_auth.py new file mode 100644 index 0000000..ada7bbd --- /dev/null +++ b/test_auth.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Simple test script to verify the authentication system setup. +This script checks if all required modules can be imported and basic functionality works. +""" + +import sys +import os + +def test_imports(): + """Test if all required modules can be imported""" + print("Testing imports...") + + try: + import firebase_admin + print("āœ… firebase-admin imported successfully") + except ImportError as e: + print(f"āŒ firebase-admin import failed: {e}") + return False + + try: + import jwt + print("āœ… PyJWT imported successfully") + except ImportError as e: + print(f"āŒ PyJWT import failed: {e}") + return False + + try: + from app.auth.models import UserSignupRequest, UserLoginRequest + print("āœ… Auth models imported successfully") + except ImportError as e: + print(f"āŒ Auth models import failed: {e}") + return False + + try: + from app.auth.firebase_auth import FirebaseAuthService + print("āœ… Firebase auth service imported successfully") + except ImportError as e: + print(f"āŒ Firebase auth service import failed: {e}") + return False + + try: + from app.auth.dependencies import get_current_user + print("āœ… Auth dependencies imported successfully") + except ImportError as e: + print(f"āŒ Auth dependencies import failed: {e}") + return False + + try: + from app.main import app + print("āœ… FastAPI app imported successfully") + except ImportError as e: + print(f"āŒ FastAPI app import failed: {e}") + return False + + return True + + +def test_environment_variables(): + """Test if required environment variables are set""" + print("\nTesting environment variables...") + + required_vars = [ + "JWT_SECRET", + "FIREBASE_CREDENTIALS", + "FIREBASE_SERVICE_ACCOUNT_PATH" + ] + + missing_vars = [] + for var in required_vars: + if not os.getenv(var): + missing_vars.append(var) + + if missing_vars: + print(f"āš ļø Missing environment variables: {', '.join(missing_vars)}") + print(" These are required for full functionality") + return False + else: + print("āœ… All required environment variables are set") + return True + + +def test_fastapi_app(): + """Test if FastAPI app can be created""" + print("\nTesting FastAPI app...") + + try: + from app.main import app + routes = [route.path for route in app.routes] + print(f"āœ… FastAPI app created successfully with {len(routes)} routes") + + # Check for auth routes + auth_routes = [route for route in routes if route.startswith('/auth')] + print(f"āœ… Found {len(auth_routes)} authentication routes") + + return True + except Exception as e: + print(f"āŒ FastAPI app test failed: {e}") + return False + + +def main(): + """Run all tests""" + print("šŸ” Testing Authentication System Setup\n") + + tests = [ + test_imports, + test_environment_variables, + test_fastapi_app + ] + + passed = 0 + total = len(tests) + + for test in tests: + try: + if test(): + passed += 1 + except Exception as e: + print(f"āŒ Test failed with exception: {e}") + + print(f"\nšŸ“Š Test Results: {passed}/{total} tests passed") + + if passed == total: + print("šŸŽ‰ All tests passed! The authentication system is ready to use.") + print("\nNext steps:") + print("1. Configure your Firebase project") + print("2. Set up environment variables in .env file") + print("3. Run: python run.py") + print("4. Visit: http://localhost:8000/docs") + else: + print("āš ļø Some tests failed. Please check the errors above.") + sys.exit(1) + + +if __name__ == "__main__": + main() +