Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 76 additions & 41 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
import jwt
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from pydantic import ValidationError
from sqlmodel import Session, select
from sqlmodel import Session

from app.core import security
from app.core.config import settings
from app.core.db import engine
from app.core.security import api_key_manager
from app.crud.organization import validate_organization
from app.crud.project import validate_project
from app.models import (
AuthContext,
Organization,
Project,
TokenPayload,
User,
)
Expand All @@ -35,57 +38,89 @@ def get_db() -> Generator[Session, None, None]:
TokenDep = Annotated[str, Depends(reusable_oauth2)]


def _authenticate_with_jwt(session: Session, token: str) -> AuthContext:
"""Validate a JWT token and return the authenticated user context."""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = TokenPayload(**payload)
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
)
except (InvalidTokenError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)

# Reject refresh tokens — they should only be used at /auth/refresh
if token_data.type == "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh tokens cannot be used for API access",
)

user = session.get(User, token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=403, detail="Inactive user")

organization: Organization | None = None
project: Project | None = None

if token_data.org_id:
organization = validate_organization(session=session, org_id=token_data.org_id)
if token_data.project_id:
project = validate_project(session=session, project_id=token_data.project_id)

return AuthContext(user=user, organization=organization, project=project)


def get_auth_context(
request: Request,
session: SessionDep,
token: TokenDep,
api_key: Annotated[str, Depends(api_key_header)],
) -> AuthContext:
"""
Verify valid authentication (API Key or JWT token) and return authenticated user context.
Verify valid authentication (API Key, JWT token, or cookie) and return authenticated user context.
Returns AuthContext with user info, project_id, and organization_id.
Authorization logic should be handled in routes.

Authentication priority:
1. X-API-KEY header
2. Authorization: Bearer <token> header
3. access_token cookie
"""
# 1. Try X-API-KEY header
if api_key:
auth_context = api_key_manager.verify(session, api_key)
if not auth_context:
raise HTTPException(status_code=401, detail="Invalid API Key")

if not auth_context.user.is_active:
raise HTTPException(status_code=403, detail="Inactive user")

if not auth_context.organization.is_active:
raise HTTPException(status_code=403, detail="Inactive Organization")

if not auth_context.project.is_active:
raise HTTPException(status_code=403, detail="Inactive Project")

return auth_context

elif token:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (InvalidTokenError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)

user = session.get(User, token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=403, detail="Inactive user")

auth_context = AuthContext(
user=user,
)
return auth_context
if auth_context:
if not auth_context.user.is_active:
raise HTTPException(status_code=403, detail="Inactive user")

if not auth_context.organization.is_active:
raise HTTPException(status_code=403, detail="Inactive Organization")

if not auth_context.project.is_active:
raise HTTPException(status_code=403, detail="Inactive Project")

return auth_context

# 2. Try Authorization: Bearer <token> header
if token:
return _authenticate_with_jwt(session, token)

# 3. Try access_token cookie
cookie_token = request.cookies.get("access_token")
if cookie_token:
return _authenticate_with_jwt(session, cookie_token)

else:
raise HTTPException(status_code=401, detail="Invalid Authorization format")
raise HTTPException(status_code=401, detail="Invalid Authorization format")


AuthContextDep = Annotated[AuthContext, Depends(get_auth_context)]
22 changes: 22 additions & 0 deletions backend/app/api/docs/auth/google.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Google OAuth Authentication

Authenticate a user via Google Sign-In by verifying the Google ID token.

## Request

- **token** (required): The Google ID token obtained from the frontend Google Sign-In flow.

## Behavior

1. Verifies the Google ID token against Google's public keys and the configured `GOOGLE_CLIENT_ID`.
2. Extracts user information (email, name, picture) from the verified token.
3. Looks up the user by email in the database.
4. If the user exists and is active, generates a JWT access token.
5. Sets the access token as an **HTTP-only secure cookie** (`access_token`) in the response.
6. Returns the access token, user details, and Google profile information.

## Error Responses

- **400**: Invalid or expired Google token, or email not verified by Google.
- **401**: No account found for the Google email address.
- **403**: User account is inactive.
2 changes: 2 additions & 0 deletions backend/app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
config,
doc_transformation_job,
documents,
google_auth,
login,
languages,
llm,
Expand Down Expand Up @@ -39,6 +40,7 @@
api_router.include_router(cron.router)
api_router.include_router(documents.router)
api_router.include_router(doc_transformation_job.router)
api_router.include_router(google_auth.router)
api_router.include_router(evaluations.router)
api_router.include_router(languages.router)
api_router.include_router(llm.router)
Expand Down
Loading
Loading