added oauth login

This commit is contained in:
Swifty
2025-12-19 23:09:26 +01:00
parent a415f471c6
commit 175ba13ebe
3 changed files with 328 additions and 66 deletions

View File

@@ -1,4 +1,4 @@
name: AutoGPT Platform - Frontend CI
name: AutoGPT Platform - Fullstack CI
on:
push:
@@ -59,8 +59,34 @@ jobs:
types:
runs-on: ubuntu-latest
needs: setup
strategy:
fail-fast: false
timeout-minutes: 20
services:
postgres:
image: pgvector/pgvector:pg18
ports:
- 5432:5432
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: your-super-secret-and-long-postgres-password
POSTGRES_DB: postgres
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:latest
ports:
- 6379:6379
rabbitmq:
image: rabbitmq:3.12-management
ports:
- 5672:5672
- 15672:15672
env:
RABBITMQ_DEFAULT_USER: rabbitmq_user_default
RABBITMQ_DEFAULT_PASS: k0VMxyIJF9S35f3x2uaw5IWAl6Y536O7
steps:
- name: Checkout repository
@@ -68,6 +94,58 @@ jobs:
with:
submodules: recursive
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Set up Python dependency cache
uses: actions/cache@v4
with:
path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
- name: Install Poetry
run: |
curl -sSL https://install.python-poetry.org | python3 -
working-directory: autogpt_platform/backend
- name: Install Python dependencies
run: poetry install
working-directory: autogpt_platform/backend
- name: Generate Prisma Client
run: poetry run prisma generate
working-directory: autogpt_platform/backend
- name: Run Database Migrations
run: poetry run prisma migrate deploy
working-directory: autogpt_platform/backend
env:
DATABASE_URL: postgresql://postgres:your-super-secret-and-long-postgres-password@localhost:5432/postgres
DIRECT_URL: postgresql://postgres:your-super-secret-and-long-postgres-password@localhost:5432/postgres
- name: Start REST server in background
run: |
poetry run serve &
echo $! > /tmp/rest_server.pid
working-directory: autogpt_platform/backend
env:
DATABASE_URL: postgresql://postgres:your-super-secret-and-long-postgres-password@localhost:5432/postgres
DIRECT_URL: postgresql://postgres:your-super-secret-and-long-postgres-password@localhost:5432/postgres
JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long
REDIS_HOST: localhost
REDIS_PORT: "6379"
ENCRYPTION_KEY: "dvziYgz0KSK8FENhju0ZYi8-fRTfAdlz6YLhdB_jhNw="
RUN_ENV: local
PORT: 8006
- name: Wait for REST server to be ready
run: |
echo "Waiting for REST server to be ready..."
timeout 120 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done'
echo "REST server is ready!"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -76,18 +154,6 @@ jobs:
- name: Enable corepack
run: corepack enable
- name: Copy default platform .env
run: |
cp ../.env.default ../.env
- name: Copy backend .env
run: |
cp ../backend/.env.default ../backend/.env
- name: Run docker compose
run: |
docker compose -f ../docker-compose.yml --profile local --profile deps_backend up -d
- name: Restore dependencies cache
uses: actions/cache@v4
with:
@@ -102,13 +168,6 @@ jobs:
- name: Setup .env
run: cp .env.default .env
- name: Wait for services to be ready
run: |
echo "Waiting for rest_server to be ready..."
timeout 60 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done' || echo "Rest server health check timeout, continuing..."
echo "Waiting for database to be ready..."
timeout 60 sh -c 'until docker compose -f ../docker-compose.yml exec -T db pg_isready -U postgres 2>/dev/null; do sleep 2; done' || echo "Database ready check timeout, continuing..."
- name: Generate API queries
run: pnpm generate:api:force
@@ -122,11 +181,10 @@ jobs:
echo "The API schema is now out of sync with the Front-end queries."
echo ""
echo "To fix this:"
echo "1. Pull the backend 'docker compose pull && docker compose up -d --build --force-recreate'"
echo "2. Run 'pnpm generate:api' locally"
echo "3. Run 'pnpm types' locally"
echo "4. Fix any TypeScript errors that may have been introduced"
echo "5. Commit and push your changes"
echo "1. Run 'pnpm generate:api' locally with the backend running"
echo "2. Run 'pnpm types' locally"
echo "3. Fix any TypeScript errors that may have been introduced"
echo "4. Commit and push your changes"
echo ""
exit 1
else
@@ -135,3 +193,7 @@ jobs:
- name: Run Typescript checks
run: pnpm types
env:
CI: true
PLAIN_OUTPUT: True

View File

@@ -36,10 +36,14 @@ class AuthEmailSender:
self.postmark = None
# Set up Jinja2 environment for templates
self.jinja_env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=True,
)
self.jinja_env: Optional[Environment] = None
if TEMPLATE_DIR.exists():
self.jinja_env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=True,
)
else:
logger.warning(f"Auth email templates directory not found: {TEMPLATE_DIR}")
def _get_frontend_url(self) -> str:
"""Get the frontend base URL for email links."""
@@ -53,6 +57,9 @@ class AuthEmailSender:
self, template_name: str, subject: str, **context
) -> tuple[str, str]:
"""Render an email template with the base template wrapper."""
if not self.jinja_env:
raise RuntimeError("Email templates not available")
# Render the content template
content_template = self.jinja_env.get_template(template_name)
content = content_template.render(**context)
@@ -100,18 +107,22 @@ class AuthEmailSender:
Returns:
True if email was sent successfully, False otherwise
"""
frontend_url = self._get_frontend_url()
reset_link = f"{frontend_url}/reset-password?token={reset_token}"
try:
frontend_url = self._get_frontend_url()
reset_link = f"{frontend_url}/reset-password?token={reset_token}"
subject, html_body = self._render_template(
"password_reset.html.jinja2",
subject="Reset Your AutoGPT Password",
reset_link=reset_link,
user_name=user_name,
frontend_url=frontend_url,
)
subject, html_body = self._render_template(
"password_reset.html.jinja2",
subject="Reset Your AutoGPT Password",
reset_link=reset_link,
user_name=user_name,
frontend_url=frontend_url,
)
return self._send_email(to_email, subject, html_body)
return self._send_email(to_email, subject, html_body)
except Exception as e:
logger.error(f"Failed to send password reset email to {to_email}: {e}")
return False
def send_email_verification(
self, to_email: str, verification_token: str, user_name: Optional[str] = None
@@ -127,18 +138,24 @@ class AuthEmailSender:
Returns:
True if email was sent successfully, False otherwise
"""
frontend_url = self._get_frontend_url()
verification_link = f"{frontend_url}/verify-email?token={verification_token}"
try:
frontend_url = self._get_frontend_url()
verification_link = (
f"{frontend_url}/verify-email?token={verification_token}"
)
subject, html_body = self._render_template(
"email_verification.html.jinja2",
subject="Verify Your AutoGPT Email",
verification_link=verification_link,
user_name=user_name,
frontend_url=frontend_url,
)
subject, html_body = self._render_template(
"email_verification.html.jinja2",
subject="Verify Your AutoGPT Email",
verification_link=verification_link,
user_name=user_name,
frontend_url=frontend_url,
)
return self._send_email(to_email, subject, html_body)
return self._send_email(to_email, subject, html_body)
except Exception as e:
logger.error(f"Failed to send verification email to {to_email}: {e}")
return False
# Singleton instance

View File

@@ -10,11 +10,15 @@ Provides endpoints for:
"""
import logging
import secrets
import time
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from pydantic import BaseModel, EmailStr, Field
from backend.util.settings import Settings
from .email import get_auth_email_sender
from .service import AuthService
@@ -25,6 +29,42 @@ router = APIRouter(prefix="/auth", tags=["auth"])
# Singleton auth service instance
_auth_service: Optional[AuthService] = None
# In-memory state storage for OAuth CSRF protection
# Format: {state_token: {"created_at": timestamp, "redirect_uri": optional_uri}}
# In production, use Redis for distributed state management
_oauth_states: dict[str, dict] = {}
_STATE_TTL_SECONDS = 600 # 10 minutes
def _cleanup_expired_states() -> None:
"""Remove expired OAuth states."""
now = time.time()
expired = [
k
for k, v in _oauth_states.items()
if now - v["created_at"] > _STATE_TTL_SECONDS
]
for k in expired:
del _oauth_states[k]
def _generate_state() -> str:
"""Generate a cryptographically secure state token."""
_cleanup_expired_states()
state = secrets.token_urlsafe(32)
_oauth_states[state] = {"created_at": time.time()}
return state
def _validate_state(state: str) -> bool:
"""Validate and consume a state token."""
if state not in _oauth_states:
return False
state_data = _oauth_states.pop(state)
if time.time() - state_data["created_at"] > _STATE_TTL_SECONDS:
return False
return True
def get_auth_service() -> AuthService:
"""Get or create the auth service singleton."""
@@ -123,14 +163,20 @@ async def register(request: RegisterRequest, background_tasks: BackgroundTasks):
)
# Create verification token and send email in background
verification_token = await auth_service.create_email_verification_token(user.id)
email_sender = get_auth_email_sender()
background_tasks.add_task(
email_sender.send_email_verification,
to_email=user.email,
verification_token=verification_token,
user_name=user.name,
)
# This is non-critical - don't fail registration if email fails
try:
verification_token = await auth_service.create_email_verification_token(
user.id
)
email_sender = get_auth_email_sender()
background_tasks.add_task(
email_sender.send_email_verification,
to_email=user.email,
verification_token=verification_token,
user_name=user.name,
)
except Exception as e:
logger.warning(f"Failed to queue verification email for {user.email}: {e}")
tokens = await auth_service.create_tokens(user)
return TokenResponse(**tokens)
@@ -298,25 +344,162 @@ async def resend_verification_email(
# ============= Google OAuth Endpoints =============
# Google userinfo endpoint for fetching user profile
GOOGLE_USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v2/userinfo"
@router.get("/google/login")
class GoogleLoginResponse(BaseModel):
"""Response model for Google OAuth login initiation."""
url: str
def _get_google_oauth_handler():
"""Get a configured GoogleOAuthHandler instance."""
# Lazy import to avoid circular imports
from backend.integrations.oauth.google import GoogleOAuthHandler
settings = Settings()
client_id = settings.secrets.google_client_id
client_secret = settings.secrets.google_client_secret
if not client_id or not client_secret:
raise HTTPException(
status_code=500,
detail="Google OAuth is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.",
)
# Construct the redirect URI - this should point to the frontend's callback
# which will then call our /auth/google/callback endpoint
frontend_base_url = settings.config.frontend_base_url or "http://localhost:3000"
redirect_uri = f"{frontend_base_url}/auth/callback"
return GoogleOAuthHandler(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
@router.get("/google/login", response_model=GoogleLoginResponse)
async def google_login(request: Request):
"""
Initiate Google OAuth flow.
Returns the Google OAuth authorization URL to redirect the user to.
"""
# TODO: Implement Google OAuth using authlib
raise HTTPException(status_code=501, detail="Google OAuth not yet implemented")
try:
handler = _get_google_oauth_handler()
state = _generate_state()
# Get the authorization URL with default scopes (email, profile, openid)
auth_url = handler.get_login_url(
scopes=[], # Will use DEFAULT_SCOPES from handler
state=state,
code_challenge=None, # Not using PKCE for server-side flow
)
logger.info(f"Generated Google OAuth URL for state: {state[:8]}...")
return GoogleLoginResponse(url=auth_url)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to initiate Google OAuth: {e}")
raise HTTPException(status_code=500, detail="Failed to initiate Google OAuth")
@router.get("/google/callback")
@router.get("/google/callback", response_model=TokenResponse)
async def google_callback(request: Request, code: str, state: Optional[str] = None):
"""
Handle Google OAuth callback.
Exchanges the authorization code for user info and creates/updates the user.
Returns tokens or redirects to frontend with tokens.
Returns access and refresh tokens.
"""
# TODO: Implement Google OAuth callback
raise HTTPException(status_code=501, detail="Google OAuth not yet implemented")
# Validate state to prevent CSRF attacks
if not state or not _validate_state(state):
logger.warning(
f"Invalid or missing OAuth state: {state[:8] if state else 'None'}..."
)
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
try:
handler = _get_google_oauth_handler()
# Exchange the authorization code for Google credentials
logger.info("Exchanging authorization code for tokens...")
google_creds = await handler.exchange_code_for_tokens(
code=code,
scopes=[], # Will use the scopes from the initial request
code_verifier=None,
)
# The handler returns OAuth2Credentials with email in username field
email = google_creds.username
if not email:
raise HTTPException(
status_code=400, detail="Failed to retrieve email from Google"
)
# Fetch full user info to get Google user ID and name
# Lazy import to avoid circular imports
from google.auth.transport.requests import AuthorizedSession
from google.oauth2.credentials import Credentials
# We need to create Google Credentials object to use with AuthorizedSession
creds = Credentials(
token=google_creds.access_token.get_secret_value(),
refresh_token=(
google_creds.refresh_token.get_secret_value()
if google_creds.refresh_token
else None
),
token_uri="https://oauth2.googleapis.com/token",
client_id=handler.client_id,
client_secret=handler.client_secret,
)
session = AuthorizedSession(creds)
userinfo_response = session.get(GOOGLE_USERINFO_ENDPOINT)
if not userinfo_response.ok:
logger.error(
f"Failed to fetch Google userinfo: {userinfo_response.status_code}"
)
raise HTTPException(
status_code=400, detail="Failed to fetch user info from Google"
)
userinfo = userinfo_response.json()
google_id = userinfo.get("id")
name = userinfo.get("name")
email_verified = userinfo.get("verified_email", False)
if not google_id:
raise HTTPException(
status_code=400, detail="Failed to retrieve Google user ID"
)
logger.info(f"Google OAuth successful for user: {email}")
# Create or update the user in our database
auth_service = get_auth_service()
user = await auth_service.create_or_update_google_user(
google_id=google_id,
email=email,
name=name,
email_verified=email_verified,
)
# Generate our JWT tokens
tokens = await auth_service.create_tokens(user)
return TokenResponse(**tokens)
except HTTPException:
raise
except Exception as e:
logger.error(f"Google OAuth callback failed: {e}")
raise HTTPException(status_code=500, detail="Failed to complete Google OAuth")