diff --git a/alembic/env.py b/alembic/env.py
index 84bfc3a..b71c997 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -8,6 +8,9 @@
from core.config import get_settings
# Import all models here so that they are registered with SQLModel metadata
+from api.auth.models import (
+ User, RefreshToken, PasswordResetToken, EmailVerificationToken
+)
from api.files.models import File
from api.samples.models import Sample, SampleAttribute
from api.settings.models import Setting
diff --git a/alembic/versions/b6847b89d202_add_user_auth_tables.py b/alembic/versions/b6847b89d202_add_user_auth_tables.py
new file mode 100644
index 0000000..99d4af2
--- /dev/null
+++ b/alembic/versions/b6847b89d202_add_user_auth_tables.py
@@ -0,0 +1,113 @@
+"""Add user auth tables
+
+Revision ID: b6847b89d202
+Revises: d89d27d47634
+Create Date: 2026-02-02 09:55:21.853229
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'b6847b89d202'
+down_revision: Union[str, Sequence[str], None] = 'd89d27d47634'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('users',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('username', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False),
+ sa.Column('hashed_password', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False),
+ sa.Column('is_verified', sa.Boolean(), nullable=False),
+ sa.Column('is_superuser', sa.Boolean(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.Column('last_login', sa.DateTime(), nullable=True),
+ sa.Column('failed_login_attempts', sa.Integer(), nullable=False),
+ sa.Column('locked_until', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
+ op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
+ op.create_table('email_verification_tokens',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('token', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('expires_at', sa.DateTime(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('used', sa.Boolean(), nullable=False),
+ sa.Column('used_at', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['username'], ['users.username'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_email_verification_tokens_token'), 'email_verification_tokens', ['token'], unique=True)
+ op.create_index(op.f('ix_email_verification_tokens_username'), 'email_verification_tokens', ['username'], unique=False)
+ op.create_table('oauth_providers',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('provider_name', sa.Enum('GOOGLE', 'GITHUB', 'MICROSOFT', name='oauthprovidername'), nullable=False),
+ sa.Column('provider_username', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('access_token', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=True),
+ sa.Column('refresh_token', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=True),
+ sa.Column('token_expires_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['username'], ['users.username'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_oauth_providers_provider_name'), 'oauth_providers', ['provider_name'], unique=False)
+ op.create_index(op.f('ix_oauth_providers_username'), 'oauth_providers', ['username'], unique=False)
+ op.create_table('password_reset_tokens',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('token', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('expires_at', sa.DateTime(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('used', sa.Boolean(), nullable=False),
+ sa.Column('used_at', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['username'], ['users.username'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True)
+ op.create_index(op.f('ix_password_reset_tokens_username'), 'password_reset_tokens', ['username'], unique=False)
+ op.create_table('refresh_tokens',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column('token', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
+ sa.Column('expires_at', sa.DateTime(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('revoked', sa.Boolean(), nullable=False),
+ sa.Column('revoked_at', sa.DateTime(), nullable=True),
+ sa.Column('device_info', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True),
+ sa.ForeignKeyConstraint(['username'], ['users.username'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_refresh_tokens_token'), 'refresh_tokens', ['token'], unique=True)
+ op.create_index(op.f('ix_refresh_tokens_username'), 'refresh_tokens', ['username'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ # Drop tables with foreign keys first (table drops automatically handle indexes and constraints)
+ op.drop_table('refresh_tokens')
+ op.drop_table('password_reset_tokens')
+ op.drop_table('oauth_providers')
+ op.drop_table('email_verification_tokens')
+ # Now drop the users table with its indexes explicitly
+ op.drop_index(op.f('ix_users_username'), table_name='users')
+ op.drop_index(op.f('ix_users_email'), table_name='users')
+ op.drop_table('users')
+ # ### end Alembic commands ###
diff --git a/api/auth/deps.py b/api/auth/deps.py
new file mode 100644
index 0000000..8f10a4e
--- /dev/null
+++ b/api/auth/deps.py
@@ -0,0 +1,156 @@
+"""
+Authentication dependencies for protecting endpoints
+"""
+from typing import Annotated
+import uuid
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from jose import JWTError
+
+from core.deps import SessionDep
+from core.security import decode_token
+from api.auth.models import User
+
+# OAuth2 scheme for token extraction
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
+
+# Optional OAuth2 scheme (doesn't raise error if no token)
+oauth2_scheme_optional = OAuth2PasswordBearer(
+ tokenUrl="/api/v1/auth/login",
+ auto_error=False
+)
+
+
+def get_current_user(
+ session: SessionDep,
+ token: Annotated[str, Depends(oauth2_scheme)]
+) -> User:
+ """
+ Get current authenticated user from JWT token
+
+ Args:
+ session: Database session
+ token: JWT access token
+
+ Returns:
+ Current User object
+
+ Raises:
+ HTTPException: If token is invalid or user not found
+ """
+ credentials_exception = HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ try:
+ payload = decode_token(token)
+ user_id_str: str | None = payload.get("sub")
+ if user_id_str is None:
+ raise credentials_exception
+
+ # Parse the UUID from the string
+ user_id = uuid.UUID(user_id_str)
+
+ except (JWTError, ValueError):
+ raise credentials_exception
+
+ user = session.get(User, user_id)
+ if user is None:
+ raise credentials_exception
+
+ return user
+
+
+def get_current_active_user(
+ current_user: Annotated[User, Depends(get_current_user)]
+) -> User:
+ """
+ Get current active user (must be active and verified)
+
+ Args:
+ current_user: Current user from get_current_user
+
+ Returns:
+ Current User object
+
+ Raises:
+ HTTPException: If user is inactive or unverified
+ """
+ if not current_user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Inactive user"
+ )
+
+ if not current_user.is_verified:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Email not verified"
+ )
+
+ return current_user
+
+
+def get_current_superuser(
+ current_user: Annotated[User, Depends(get_current_active_user)]
+) -> User:
+ """
+ Get current superuser (must be active, verified, and superuser)
+
+ Args:
+ current_user: Current user from get_current_active_user
+
+ Returns:
+ Current User object
+
+ Raises:
+ HTTPException: If user is not a superuser
+ """
+ if not current_user.is_superuser:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Not enough permissions"
+ )
+
+ return current_user
+
+
+def optional_current_user(
+ session: SessionDep,
+ token: Annotated[str | None, Depends(oauth2_scheme_optional)]
+) -> User | None:
+ """
+ Get current user if token is provided, None otherwise
+
+ Args:
+ session: Database session
+ token: Optional JWT access token
+
+ Returns:
+ Current User object or None
+ """
+ if token is None:
+ return None
+
+ try:
+ payload = decode_token(token)
+ user_id_str: str | None = payload.get("sub")
+ if user_id_str is None:
+ return None
+
+ # Parse the UUID from the string
+ user_id = uuid.UUID(user_id_str)
+
+ return session.get(User, user_id)
+ except (JWTError, ValueError):
+ return None
+
+
+# Type aliases for cleaner endpoint signatures
+CurrentUser = Annotated[User, Depends(get_current_user)]
+CurrentActiveUser = Annotated[User, Depends(get_current_active_user)]
+CurrentSuperuser = Annotated[User, Depends(get_current_superuser)]
+OptionalUser = Annotated[User | None, Depends(optional_current_user)]
diff --git a/api/auth/models.py b/api/auth/models.py
new file mode 100644
index 0000000..9101d24
--- /dev/null
+++ b/api/auth/models.py
@@ -0,0 +1,208 @@
+"""
+Authentication models for users, tokens, and OAuth providers
+"""
+from datetime import datetime, timezone
+from enum import Enum
+import uuid
+from sqlmodel import Field, SQLModel
+from pydantic import EmailStr, ConfigDict
+
+
+class User(SQLModel, table=True):
+ """User model with authentication support"""
+
+ __tablename__ = "users"
+ __searchable__ = ["email", "username", "full_name"]
+
+ # Primary identifiers
+ id: uuid.UUID | None = Field(default_factory=uuid.uuid4, primary_key=True)
+
+ # Authentication
+ email: str = Field(unique=True, index=True, max_length=255)
+ username: str = Field(unique=True, index=True, max_length=50)
+ hashed_password: str | None = Field(default=None, max_length=255) # None for OAuth-only
+
+ # Profile
+ full_name: str | None = Field(default=None, max_length=255)
+
+ # Status flags
+ is_active: bool = Field(default=True)
+ is_verified: bool = Field(default=False)
+ is_superuser: bool = Field(default=False)
+
+ # Timestamps
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ last_login: datetime | None = Field(default=None)
+
+ # Security
+ failed_login_attempts: int = Field(default=0)
+ locked_until: datetime | None = Field(default=None)
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class RefreshToken(SQLModel, table=True):
+ """Refresh token for maintaining user sessions"""
+
+ __tablename__ = "refresh_tokens"
+
+ id: uuid.UUID | None = Field(default_factory=uuid.uuid4, primary_key=True)
+ username: str = Field(foreign_key="users.username", index=True)
+ token: str = Field(unique=True, index=True, max_length=500)
+ expires_at: datetime
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ revoked: bool = Field(default=False)
+ revoked_at: datetime | None = Field(default=None)
+ device_info: str | None = Field(default=None, max_length=500)
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class OAuthProviderName(str, Enum):
+ """Supported OAuth providers"""
+ GOOGLE = "google"
+ GITHUB = "github"
+ MICROSOFT = "microsoft"
+
+
+class OAuthProvider(SQLModel, table=True):
+ """OAuth provider linkage for external authentication"""
+
+ __tablename__ = "oauth_providers"
+
+ id: uuid.UUID | None = Field(default_factory=uuid.uuid4, primary_key=True)
+ username: str = Field(foreign_key="users.username", index=True)
+ provider_name: OAuthProviderName = Field(index=True)
+ provider_username: str = Field(max_length=255)
+
+ # OAuth tokens (should be encrypted in production)
+ access_token: str | None = Field(default=None, max_length=1000)
+ refresh_token: str | None = Field(default=None, max_length=1000)
+ token_expires_at: datetime | None = Field(default=None)
+
+ # Timestamps
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class PasswordResetToken(SQLModel, table=True):
+ """Password reset token for secure password recovery"""
+
+ __tablename__ = "password_reset_tokens"
+
+ id: uuid.UUID | None = Field(default_factory=uuid.uuid4, primary_key=True)
+ username: str = Field(foreign_key="users.username", index=True)
+ token: str = Field(unique=True, index=True, max_length=255)
+ expires_at: datetime
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ used: bool = Field(default=False)
+ used_at: datetime | None = Field(default=None)
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class EmailVerificationToken(SQLModel, table=True):
+ """Email verification token for confirming user email addresses"""
+
+ __tablename__ = "email_verification_tokens"
+
+ id: uuid.UUID | None = Field(default_factory=uuid.uuid4, primary_key=True)
+ username: str = Field(foreign_key="users.username", index=True)
+ token: str = Field(unique=True, index=True, max_length=255)
+ expires_at: datetime
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ used: bool = Field(default=False)
+ used_at: datetime | None = Field(default=None)
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+# Request/Response Models
+
+class UserRegister(SQLModel):
+ """User registration request"""
+ email: EmailStr
+ username: str = Field(min_length=3, max_length=50)
+ password: str = Field(max_length=100) # Validation done in service layer
+ full_name: str | None = None
+
+
+class UserLogin(SQLModel):
+ """User login request"""
+ email: EmailStr
+ password: str
+
+
+class TokenResponse(SQLModel):
+ """Authentication token response"""
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+ expires_in: int
+
+
+class RefreshTokenRequest(SQLModel):
+ """Refresh token request"""
+ refresh_token: str
+
+
+class PasswordResetRequest(SQLModel):
+ """Password reset request"""
+ email: EmailStr
+
+
+class PasswordResetConfirm(SQLModel):
+ """Password reset confirmation"""
+ token: str
+ new_password: str = Field(max_length=100) # Validation in service layer
+
+
+class PasswordChange(SQLModel):
+ """Password change request"""
+ current_password: str
+ new_password: str = Field(max_length=100) # Validation in service layer
+
+
+class EmailVerificationRequest(SQLModel):
+ """Email verification request"""
+ token: str
+
+
+class ResendVerificationRequest(SQLModel):
+ """Resend verification email request"""
+ email: EmailStr
+
+
+class OAuthLinkRequest(SQLModel):
+ """Link OAuth provider to account"""
+ code: str
+
+
+class UserPublic(SQLModel):
+ """Public user information"""
+ email: str
+ username: str
+ full_name: str | None
+ is_active: bool
+ is_verified: bool
+ created_at: datetime
+ last_login: datetime | None
+ oauth_providers: list[str] = []
+
+
+class UserUpdate(SQLModel):
+ """User profile update"""
+ full_name: str | None = None
+ email: EmailStr | None = None
+ username: str | None = None
+
+
+class UsersPublic(SQLModel):
+ """Paginated users response"""
+ data: list[UserPublic]
+ count: int
+ page: int
+ per_page: int
diff --git a/api/auth/oauth2_service.py b/api/auth/oauth2_service.py
new file mode 100644
index 0000000..496329b
--- /dev/null
+++ b/api/auth/oauth2_service.py
@@ -0,0 +1,484 @@
+"""
+OAuth2 service for external authentication providers
+"""
+import logging
+from datetime import datetime, timezone
+import uuid
+
+from fastapi import HTTPException, status
+from sqlmodel import Session, select
+import httpx
+
+from api.auth.models import (
+ User, OAuthProvider, OAuthProviderName
+)
+from core.config import get_settings
+from core.security import generate_secure_token
+
+logger = logging.getLogger(__name__)
+
+
+class OAuth2ProviderConfig:
+ """Configuration for OAuth2 providers"""
+
+ PROVIDERS = {
+ "google": {
+ "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
+ "token_url": "https://oauth2.googleapis.com/token",
+ "userinfo_url": "https://www.googleapis.com/oauth2/v2/userinfo",
+ "scopes": ["openid", "email", "profile"],
+ },
+ "github": {
+ "authorize_url": "https://github.com/login/oauth/authorize",
+ "token_url": "https://github.com/login/oauth/access_token",
+ "userinfo_url": "https://api.github.com/user",
+ "scopes": ["user:email", "read:user"],
+ },
+ "microsoft": {
+ "authorize_url": (
+ "https://login.microsoftonline.com/common/oauth2/v2.0/"
+ "authorize"
+ ),
+ "token_url": (
+ "https://login.microsoftonline.com/common/oauth2/v2.0/"
+ "token"
+ ),
+ "userinfo_url": "https://graph.microsoft.com/v1.0/me",
+ "scopes": ["openid", "email", "profile"],
+ },
+ }
+
+ @classmethod
+ def get_provider_config(cls, provider: str) -> dict:
+ """Get configuration for OAuth provider"""
+ if provider not in cls.PROVIDERS:
+ raise ValueError(f"Unsupported provider: {provider}")
+ return cls.PROVIDERS[provider]
+
+
+def get_authorization_url(
+ provider: str,
+ redirect_uri: str,
+ state: str | None = None
+) -> str:
+ """
+ Generate OAuth2 authorization URL
+
+ Args:
+ provider: OAuth provider name (google, github, microsoft)
+ redirect_uri: Callback URL after authorization
+ state: Optional state parameter for CSRF protection
+
+ Returns:
+ Authorization URL to redirect user to
+
+ Raises:
+ ValueError: If provider is not supported
+ HTTPException: If provider credentials not configured
+ """
+ settings = get_settings()
+ config = OAuth2ProviderConfig.get_provider_config(provider)
+
+ # Get client ID based on provider
+ client_id = None
+ if provider == "google":
+ client_id = settings.OAUTH_GOOGLE_CLIENT_ID
+ elif provider == "github":
+ client_id = settings.OAUTH_GITHUB_CLIENT_ID
+ elif provider == "microsoft":
+ client_id = settings.OAUTH_MICROSOFT_CLIENT_ID
+
+ if not client_id:
+ raise HTTPException(
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
+ detail=f"{provider.title()} OAuth not configured"
+ )
+
+ # Generate state if not provided
+ if not state:
+ state = generate_secure_token(16)
+
+ # Build authorization URL
+ scopes = " ".join(config["scopes"])
+ params = {
+ "client_id": client_id,
+ "redirect_uri": redirect_uri,
+ "response_type": "code",
+ "scope": scopes,
+ "state": state,
+ }
+
+ # GitHub uses comma-separated scopes
+ if provider == "github":
+ params["scope"] = ",".join(config["scopes"])
+
+ query_string = "&".join(f"{k}={v}" for k, v in params.items())
+ return f"{config['authorize_url']}?{query_string}"
+
+
+async def exchange_code_for_token(
+ provider: str,
+ code: str,
+ redirect_uri: str
+) -> dict:
+ """
+ Exchange authorization code for access token
+
+ Args:
+ provider: OAuth provider name
+ code: Authorization code from callback
+ redirect_uri: Callback URL (must match authorization request)
+
+ Returns:
+ Token response from provider
+
+ Raises:
+ HTTPException: If token exchange fails
+ """
+ settings = get_settings()
+ config = OAuth2ProviderConfig.get_provider_config(provider)
+
+ # Get client credentials
+ client_id = None
+ client_secret = None
+ if provider == "google":
+ client_id = settings.OAUTH_GOOGLE_CLIENT_ID
+ client_secret = settings.OAUTH_GOOGLE_CLIENT_SECRET
+ elif provider == "github":
+ client_id = settings.OAUTH_GITHUB_CLIENT_ID
+ client_secret = settings.OAUTH_GITHUB_CLIENT_SECRET
+ elif provider == "microsoft":
+ client_id = settings.OAUTH_MICROSOFT_CLIENT_ID
+ client_secret = settings.OAUTH_MICROSOFT_CLIENT_SECRET
+
+ if not client_id or not client_secret:
+ raise HTTPException(
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
+ detail=f"{provider.title()} OAuth not configured"
+ )
+
+ # Exchange code for token
+ data = {
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "code": code,
+ "redirect_uri": redirect_uri,
+ "grant_type": "authorization_code",
+ }
+
+ headers = {"Accept": "application/json"}
+
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ config["token_url"],
+ data=data,
+ headers=headers
+ )
+ response.raise_for_status()
+ return response.json()
+ except httpx.HTTPError as e:
+ logger.error(f"Token exchange failed: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Failed to exchange authorization code"
+ )
+
+
+async def get_user_info(provider: str, access_token: str) -> dict:
+ """
+ Get user information from OAuth provider
+
+ Args:
+ provider: OAuth provider name
+ access_token: Access token from provider
+
+ Returns:
+ User information from provider
+
+ Raises:
+ HTTPException: If user info request fails
+ """
+ config = OAuth2ProviderConfig.get_provider_config(provider)
+
+ headers = {
+ "Authorization": f"Bearer {access_token}",
+ "Accept": "application/json"
+ }
+
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ config["userinfo_url"],
+ headers=headers
+ )
+ response.raise_for_status()
+ user_data = response.json()
+
+ # Normalize user data across providers
+ return _normalize_user_data(provider, user_data)
+ except httpx.HTTPError as e:
+ logger.error(f"Failed to get user info: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Failed to get user information"
+ )
+
+
+def _normalize_user_data(provider: str, data: dict) -> dict:
+ """
+ Normalize user data from different providers
+
+ Args:
+ provider: OAuth provider name
+ data: Raw user data from provider
+
+ Returns:
+ Normalized user data with standard fields
+ """
+ if provider == "google":
+ return {
+ "provider_user_id": data.get("id"),
+ "email": data.get("email"),
+ "name": data.get("name"),
+ "picture": data.get("picture"),
+ }
+ elif provider == "github":
+ return {
+ "provider_user_id": str(data.get("id")),
+ "email": data.get("email"),
+ "name": data.get("name") or data.get("login"),
+ "picture": data.get("avatar_url"),
+ }
+ elif provider == "microsoft":
+ return {
+ "provider_user_id": data.get("id"),
+ "email": data.get("mail") or data.get("userPrincipalName"),
+ "name": data.get("displayName"),
+ "picture": None,
+ }
+ else:
+ return data
+
+
+def find_or_create_oauth_user(
+ session: Session,
+ provider: str,
+ provider_data: dict,
+ access_token: str,
+ refresh_token: str | None = None
+) -> User:
+ """
+ Find existing user or create new user from OAuth data
+
+ Args:
+ session: Database session
+ provider: OAuth provider name
+ provider_data: Normalized user data from provider
+ access_token: OAuth access token
+ refresh_token: OAuth refresh token (optional)
+
+ Returns:
+ User object (existing or newly created)
+
+ Raises:
+ HTTPException: If user creation fails
+ """
+ provider_user_id = provider_data.get("provider_user_id")
+ email = provider_data.get("email")
+
+ if not provider_user_id or not email:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid user data from OAuth provider"
+ )
+
+ # Check if OAuth provider link already exists
+ statement = select(OAuthProvider).where(
+ OAuthProvider.provider_name == provider,
+ OAuthProvider.provider_user_id == provider_user_id
+ )
+ oauth_link = session.exec(statement).first()
+
+ if oauth_link:
+ # User already exists, update tokens
+ oauth_link.access_token = access_token
+ oauth_link.refresh_token = refresh_token
+ oauth_link.updated_at = datetime.now(timezone.utc)
+ session.add(oauth_link)
+ session.commit()
+
+ # Return existing user
+ user = session.get(User, oauth_link.user_id)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found"
+ )
+ return user
+
+ # Check if user with email already exists
+ statement = select(User).where(User.email == email)
+ user = session.exec(statement).first()
+
+ if not user:
+ # Create new user
+ username = email.split("@")[0]
+ # Ensure unique username
+ base_username = username
+ counter = 1
+ while True:
+ statement = select(User).where(User.username == username)
+ if not session.exec(statement).first():
+ break
+ username = f"{base_username}{counter}"
+ counter += 1
+
+ user = User(
+ user_id=User.generate_user_id(),
+ email=email,
+ username=username,
+ full_name=provider_data.get("name"),
+ hashed_password=None, # OAuth-only user
+ is_active=True,
+ is_verified=True, # Email verified by OAuth provider
+ )
+ session.add(user)
+ session.commit()
+ session.refresh(user)
+
+ # Link OAuth provider to user
+ oauth_provider = OAuthProvider(
+ user_id=user.id,
+ provider_name=OAuthProviderName(provider),
+ provider_user_id=provider_user_id,
+ access_token=access_token,
+ refresh_token=refresh_token
+ )
+ session.add(oauth_provider)
+ session.commit()
+
+ return user
+
+
+def link_oauth_account(
+ session: Session,
+ user_id: uuid.UUID,
+ provider: str,
+ provider_data: dict,
+ access_token: str,
+ refresh_token: str | None = None
+) -> OAuthProvider:
+ """
+ Link OAuth provider to existing user account
+
+ Args:
+ session: Database session
+ user_id: User ID to link to
+ provider: OAuth provider name
+ provider_data: Normalized user data from provider
+ access_token: OAuth access token
+ refresh_token: OAuth refresh token (optional)
+
+ Returns:
+ Created OAuthProvider link
+
+ Raises:
+ HTTPException: If link already exists or creation fails
+ """
+ provider_user_id = provider_data.get("provider_user_id")
+
+ if not provider_user_id:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid user data from OAuth provider"
+ )
+
+ # Check if already linked
+ statement = select(OAuthProvider).where(
+ OAuthProvider.user_id == user_id,
+ OAuthProvider.provider_name == provider
+ )
+ existing = session.exec(statement).first()
+
+ if existing:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"{provider.title()} account already linked"
+ )
+
+ # Create link
+ oauth_provider = OAuthProvider(
+ user_id=user_id,
+ provider_name=OAuthProviderName(provider),
+ provider_user_id=provider_user_id,
+ access_token=access_token,
+ refresh_token=refresh_token
+ )
+ session.add(oauth_provider)
+ session.commit()
+ session.refresh(oauth_provider)
+
+ return oauth_provider
+
+
+def unlink_oauth_account(
+ session: Session,
+ user_id: uuid.UUID,
+ provider: str
+) -> bool:
+ """
+ Unlink OAuth provider from user account
+
+ Args:
+ session: Database session
+ user_id: User ID
+ provider: OAuth provider name
+
+ Returns:
+ True if unlinked successfully
+
+ Raises:
+ HTTPException: If cannot unlink (last auth method)
+ """
+ # Get user
+ user = session.get(User, user_id)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found"
+ )
+
+ # Check if user has password
+ has_password = user.hashed_password is not None
+
+ # Count OAuth providers
+ statement = select(OAuthProvider).where(
+ OAuthProvider.user_id == user_id
+ )
+ oauth_count = len(session.exec(statement).all())
+
+ # Prevent unlinking if it's the only auth method
+ if not has_password and oauth_count <= 1:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Cannot unlink last authentication method"
+ )
+
+ # Find and delete OAuth link
+ statement = select(OAuthProvider).where(
+ OAuthProvider.user_id == user_id,
+ OAuthProvider.provider_name == provider
+ )
+ oauth_link = session.exec(statement).first()
+
+ if not oauth_link:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"{provider.title()} account not linked"
+ )
+
+ session.delete(oauth_link)
+ session.commit()
+
+ return True
diff --git a/api/auth/oauth_routes.py b/api/auth/oauth_routes.py
new file mode 100644
index 0000000..b6f07c1
--- /dev/null
+++ b/api/auth/oauth_routes.py
@@ -0,0 +1,262 @@
+"""
+OAuth2 authentication endpoints for external providers
+"""
+from fastapi import APIRouter, HTTPException, status, Query
+from fastapi.responses import RedirectResponse
+
+from core.deps import SessionDep
+from core.security import create_access_token, create_refresh_token
+from core.config import get_settings
+from api.auth.models import TokenResponse, OAuthLinkRequest
+from api.auth.deps import CurrentUser
+import api.auth.oauth2_service as oauth2_service
+
+router = APIRouter(prefix="/auth/oauth", tags=["OAuth2 Authentication"])
+
+
+@router.get("/{provider}/authorize")
+def oauth_authorize(
+ provider: str,
+ redirect_uri: str | None = Query(None)
+) -> RedirectResponse:
+ """
+ Initiate OAuth2 authorization flow
+
+ Redirects user to OAuth provider's authorization page.
+
+ Args:
+ provider: OAuth provider (google, github, microsoft)
+ redirect_uri: Optional custom redirect URI
+
+ Returns:
+ Redirect to provider authorization page
+
+ Raises:
+ 501: Provider not configured
+ 400: Invalid provider
+ """
+ settings = get_settings()
+
+ # Use default redirect URI if not provided
+ if not redirect_uri:
+ redirect_uri = (
+ f"{settings.client_origin}/api/v1/auth/oauth/"
+ f"{provider}/callback"
+ )
+
+ try:
+ # Generate authorization URL
+ auth_url = oauth2_service.get_authorization_url(
+ provider,
+ redirect_uri
+ )
+ return RedirectResponse(url=auth_url)
+ except ValueError as e:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(e)
+ )
+
+
+@router.get("/{provider}/callback")
+async def oauth_callback(
+ session: SessionDep,
+ provider: str,
+ code: str = Query(...),
+ state: str | None = Query(None),
+ redirect_uri: str | None = Query(None)
+) -> TokenResponse:
+ """
+ OAuth2 callback handler
+
+ Handles the callback from OAuth provider after user authorization.
+ Exchanges code for tokens and creates/updates user account.
+
+ Args:
+ session: Database session
+ provider: OAuth provider name
+ code: Authorization code from provider
+ state: State parameter for CSRF protection
+ redirect_uri: Redirect URI (must match authorization request)
+
+ Returns:
+ Access and refresh tokens
+
+ Raises:
+ 400: Invalid code or failed to get user info
+ 501: Provider not configured
+ """
+ settings = get_settings()
+
+ # Use default redirect URI if not provided
+ if not redirect_uri:
+ redirect_uri = (
+ f"{settings.client_origin}/api/v1/auth/oauth/"
+ f"{provider}/callback"
+ )
+
+ try:
+ # Exchange code for access token
+ token_response = await oauth2_service.exchange_code_for_token(
+ provider,
+ code,
+ redirect_uri
+ )
+
+ access_token = token_response.get("access_token")
+ refresh_token_oauth = token_response.get("refresh_token")
+
+ if not access_token:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Failed to get access token"
+ )
+
+ # Get user info from provider
+ user_info = await oauth2_service.get_user_info(
+ provider,
+ access_token
+ )
+
+ # Find or create user
+ user = oauth2_service.find_or_create_oauth_user(
+ session,
+ provider,
+ user_info,
+ access_token,
+ refresh_token_oauth
+ )
+
+ # Create our own JWT tokens
+ jwt_access_token = create_access_token({"sub": str(user.id)})
+ jwt_refresh_token = create_refresh_token(
+ session,
+ user.username,
+ f"OAuth2:{provider}"
+ )
+
+ return TokenResponse(
+ access_token=jwt_access_token,
+ refresh_token=jwt_refresh_token.token,
+ token_type="bearer",
+ expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"OAuth authentication failed: {str(e)}"
+ )
+
+
+@router.post("/{provider}/link")
+async def link_oauth_provider(
+ session: SessionDep,
+ current_user: CurrentUser,
+ provider: str,
+ link_request: OAuthLinkRequest
+) -> dict:
+ """
+ Link OAuth provider to existing account
+
+ Links an OAuth provider account to the currently authenticated user.
+
+ Args:
+ session: Database session
+ current_user: Current authenticated user
+ provider: OAuth provider name
+ link_request: OAuth authorization code
+
+ Returns:
+ Success message
+
+ Raises:
+ 400: Failed to link account
+ 409: Provider already linked
+ 401: Not authenticated
+ """
+ settings = get_settings()
+
+ # Build redirect URI
+ redirect_uri = (
+ f"{settings.client_origin}/api/v1/auth/oauth/{provider}/callback"
+ )
+
+ try:
+ # Exchange code for access token
+ token_response = await oauth2_service.exchange_code_for_token(
+ provider,
+ link_request.code,
+ redirect_uri
+ )
+
+ access_token = token_response.get("access_token")
+ refresh_token = token_response.get("refresh_token")
+
+ if not access_token:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Failed to get access token"
+ )
+
+ # Get user info from provider
+ user_info = await oauth2_service.get_user_info(
+ provider,
+ access_token
+ )
+
+ # Link provider to current user
+ oauth2_service.link_oauth_account(
+ session,
+ current_user.id,
+ provider,
+ user_info,
+ access_token,
+ refresh_token
+ )
+
+ return {"message": f"{provider.title()} account linked successfully"}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Failed to link account: {str(e)}"
+ )
+
+
+@router.delete("/{provider}/unlink")
+def unlink_oauth_provider(
+ session: SessionDep,
+ current_user: CurrentUser,
+ provider: str
+) -> dict:
+ """
+ Unlink OAuth provider from account
+
+ Removes the OAuth provider link from the user's account.
+ Cannot unlink if it's the only authentication method.
+
+ Args:
+ session: Database session
+ current_user: Current authenticated user
+ provider: OAuth provider name
+
+ Returns:
+ Success message
+
+ Raises:
+ 400: Cannot unlink last auth method
+ 404: Provider not linked
+ 401: Not authenticated
+ """
+ oauth2_service.unlink_oauth_account(
+ session,
+ current_user.id,
+ provider
+ )
+
+ return {"message": f"{provider.title()} account unlinked successfully"}
diff --git a/api/auth/routes.py b/api/auth/routes.py
new file mode 100644
index 0000000..9f0533d
--- /dev/null
+++ b/api/auth/routes.py
@@ -0,0 +1,353 @@
+"""
+Authentication endpoints for login, registration, and token management
+"""
+from typing import Annotated
+
+from fastapi import (
+ APIRouter, Depends, HTTPException, status, Request
+)
+from fastapi.security import OAuth2PasswordRequestForm
+
+from core.deps import SessionDep
+from core.security import create_access_token, create_refresh_token
+from core.config import get_settings
+from api.auth.models import (
+ User, UserRegister, UserPublic, TokenResponse,
+ RefreshTokenRequest, PasswordResetRequest, PasswordResetConfirm,
+ EmailVerificationRequest, ResendVerificationRequest, PasswordChange
+)
+from api.auth.deps import CurrentUser, CurrentActiveUser
+import api.auth.services as auth_services
+
+router = APIRouter(prefix="/auth", tags=["Authentication"])
+
+
+@router.post(
+ "/register",
+ response_model=UserPublic,
+ status_code=status.HTTP_201_CREATED
+)
+def register(
+ session: SessionDep,
+ user_data: UserRegister
+) -> User:
+ """
+ Register a new user account
+
+ Creates a new user with email/password authentication.
+ Sends verification email to confirm email address.
+
+ Args:
+ session: Database session
+ user_data: User registration data
+
+ Returns:
+ Created user information
+
+ Raises:
+ 409: Email or username already exists
+ 400: Invalid password strength
+ """
+ user = auth_services.register_user(session, user_data)
+ return user
+
+
+@router.post("/login", response_model=TokenResponse)
+def login(
+ session: SessionDep,
+ request: Request,
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
+) -> TokenResponse:
+ """
+ Login with email and password
+
+ Authenticates user and returns access and refresh tokens.
+ Username field should contain the email address.
+
+ Args:
+ session: Database session
+ request: HTTP request (for device info)
+ form_data: OAuth2 form with username (email) and password
+
+ Returns:
+ Access token and refresh token
+
+ Raises:
+ 401: Invalid credentials
+ 423: Account locked due to failed attempts
+ """
+ # Authenticate user (username field contains email)
+ user = auth_services.authenticate_user(
+ session,
+ form_data.username,
+ form_data.password
+ )
+
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect email or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ # Get device info from request
+ user_agent = request.headers.get("user-agent", "unknown")
+ device_info = f"{user_agent[:100]}"
+
+ # Create tokens
+ settings = get_settings()
+ access_token = create_access_token({"sub": str(user.id)})
+ refresh_token = create_refresh_token(session, user.username, device_info)
+
+ return TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token.token,
+ token_type="bearer",
+ expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
+ )
+
+
+@router.post("/refresh", response_model=TokenResponse)
+def refresh_token(
+ session: SessionDep,
+ token_data: RefreshTokenRequest
+) -> TokenResponse:
+ """
+ Refresh access token
+
+ Uses refresh token to obtain new access and refresh tokens.
+ Old refresh token is revoked (token rotation).
+
+ Args:
+ session: Database session
+ token_data: Refresh token
+
+ Returns:
+ New access token and refresh token
+
+ Raises:
+ 401: Invalid or expired refresh token
+ """
+ token_response = auth_services.refresh_access_token(
+ session,
+ token_data.refresh_token
+ )
+ return TokenResponse(**token_response)
+
+
+@router.post("/logout")
+def logout(
+ session: SessionDep,
+ token_data: RefreshTokenRequest
+) -> dict:
+ """
+ Logout user
+
+ Revokes the refresh token to prevent further token refreshes.
+ Access token will remain valid until expiration.
+
+ Args:
+ session: Database session
+ token_data: Refresh token to revoke
+
+ Returns:
+ Success message
+ """
+ auth_services.revoke_refresh_token(session, token_data.refresh_token)
+ return {"message": "Logged out successfully"}
+
+
+@router.get("/me", response_model=UserPublic)
+def get_current_user_info(
+ current_user: CurrentUser
+) -> User:
+ """
+ Get current user profile
+
+ Returns information about the authenticated user.
+
+ Args:
+ current_user: Current authenticated user
+
+ Returns:
+ User profile information
+
+ Raises:
+ 401: Not authenticated
+ """
+ return current_user
+
+
+@router.post("/password-reset/request")
+def request_password_reset(
+ session: SessionDep,
+ reset_request: PasswordResetRequest
+) -> dict:
+ """
+ Request password reset
+
+ Sends password reset email if account exists.
+ Always returns success to prevent email enumeration.
+
+ Args:
+ session: Database session
+ reset_request: Email address
+
+ Returns:
+ Success message
+ """
+ auth_services.initiate_password_reset(session, reset_request.email)
+ return {
+ "message": "If the email exists, a password reset link "
+ "has been sent"
+ }
+
+
+@router.post("/password-reset/confirm")
+def confirm_password_reset(
+ session: SessionDep,
+ reset_data: PasswordResetConfirm
+) -> dict:
+ """
+ Confirm password reset
+
+ Resets password using the token from email.
+
+ Args:
+ session: Database session
+ reset_data: Reset token and new password
+
+ Returns:
+ Success message
+
+ Raises:
+ 400: Invalid or expired token
+ """
+ auth_services.complete_password_reset(
+ session,
+ reset_data.token,
+ reset_data.new_password
+ )
+ return {"message": "Password reset successful"}
+
+
+@router.post("/password/change")
+def change_password(
+ session: SessionDep,
+ current_user: CurrentActiveUser,
+ password_data: PasswordChange
+) -> dict:
+ """
+ Change password
+
+ Changes password for authenticated user.
+ Requires current password for verification.
+
+ Args:
+ session: Database session
+ current_user: Current authenticated user
+ password_data: Current and new password
+
+ Returns:
+ Success message
+
+ Raises:
+ 400: Invalid current password or weak new password
+ 401: Not authenticated
+ """
+ from core.security import verify_password, hash_password
+ from core.security import validate_password_strength
+
+ # Verify current password
+ if not current_user.hashed_password or not verify_password(
+ password_data.current_password,
+ current_user.hashed_password
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Current password is incorrect"
+ )
+
+ # Validate new password strength
+ is_valid, error_msg = validate_password_strength(
+ password_data.new_password
+ )
+ if not is_valid:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=error_msg
+ )
+
+ # Update password
+ from datetime import datetime, timezone
+ current_user.hashed_password = hash_password(
+ password_data.new_password
+ )
+ current_user.updated_at = datetime.now(timezone.utc)
+ session.add(current_user)
+ session.commit()
+
+ return {"message": "Password changed successfully"}
+
+
+@router.post("/verify-email")
+def verify_email(
+ session: SessionDep,
+ verification_data: EmailVerificationRequest
+) -> dict:
+ """
+ Verify email address
+
+ Verifies user email using token from verification email.
+
+ Args:
+ session: Database session
+ verification_data: Verification token
+
+ Returns:
+ Success message
+
+ Raises:
+ 400: Invalid or expired token
+ """
+ auth_services.verify_email(session, verification_data.token)
+ return {"message": "Email verified successfully"}
+
+
+@router.post("/resend-verification")
+def resend_verification(
+ session: SessionDep,
+ resend_request: ResendVerificationRequest
+) -> dict:
+ """
+ Resend verification email
+
+ Sends a new verification email to the user.
+
+ Args:
+ session: Database session
+ resend_request: Email address
+
+ Returns:
+ Success message
+
+ Raises:
+ 404: User not found
+ 400: Email already verified
+ """
+ user = auth_services.get_user_by_email(session, resend_request.email)
+
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found"
+ )
+
+ if user.is_verified:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Email already verified"
+ )
+
+ auth_services.create_and_send_verification_email(session, user)
+ return {"message": "Verification email sent"}
diff --git a/api/auth/services.py b/api/auth/services.py
new file mode 100644
index 0000000..4ae882d
--- /dev/null
+++ b/api/auth/services.py
@@ -0,0 +1,476 @@
+"""
+Authentication service layer for user management and authentication
+"""
+from datetime import datetime, timedelta, timezone
+import logging
+
+from fastapi import HTTPException, status
+from sqlmodel import Session, select
+
+from api.auth.models import (
+ User, UserRegister, RefreshToken, PasswordResetToken,
+ EmailVerificationToken
+)
+from core.security import (
+ hash_password, verify_password, create_access_token,
+ create_refresh_token, generate_secure_token, validate_password_strength
+)
+from core.config import get_settings
+from core.email import send_password_reset_email, send_verification_email
+
+
+logger = logging.getLogger(__name__)
+
+
+def ensure_timezone_aware(dt: datetime | None) -> datetime | None:
+ """
+ Ensure datetime is timezone-aware (UTC).
+ SQLite stores datetimes without timezone info, so we need to add it back.
+
+ Args:
+ dt: Datetime that may be naive or aware
+
+ Returns:
+ Timezone-aware datetime in UTC, or None if input is None
+ """
+ if dt is None:
+ return None
+ if dt.tzinfo is None:
+ # Assume UTC for naive datetimes from database
+ return dt.replace(tzinfo=timezone.utc)
+ return dt
+
+
+def authenticate_user(
+ session: Session, email: str, password: str
+) -> User | None:
+ """
+ Authenticate user with email and password
+
+ Args:
+ session: Database session
+ email: User email
+ password: Plain text password
+
+ Returns:
+ User object if authentication successful, None otherwise
+ """
+ # Find user by email
+ statement = select(User).where(User.email == email)
+ user = session.exec(statement).first()
+
+ if not user:
+ return None
+
+ # Check if account is locked
+ locked_until = ensure_timezone_aware(user.locked_until)
+ if locked_until and locked_until > datetime.now(timezone.utc):
+ raise HTTPException(
+ status_code=status.HTTP_423_LOCKED,
+ detail=(
+ "Account is temporarily locked due to "
+ "too many failed login attempts"
+ )
+ )
+
+ # Verify password
+ if not user.hashed_password or not verify_password(
+ password, user.hashed_password
+ ):
+ # Increment failed login attempts
+ increment_failed_login(session, user)
+ return None
+
+ # Reset failed login attempts on successful login
+ reset_failed_login(session, user)
+
+ # Update last login
+ update_last_login(session, user.id)
+
+ return user
+
+
+def register_user(session: Session, user_data: UserRegister) -> User:
+ """
+ Register a new user
+
+ Args:
+ session: Database session
+ user_data: User registration data
+
+ Returns:
+ Created User object
+
+ Raises:
+ HTTPException: If email or username already exists
+ """
+ # Validate password strength
+ is_valid, error_msg = validate_password_strength(user_data.password)
+ if not is_valid:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=error_msg
+ )
+
+ # Check if email already exists
+ statement = select(User).where(User.email == user_data.email)
+ if session.exec(statement).first():
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Email already registered"
+ )
+
+ # Check if username already exists
+ statement = select(User).where(User.username == user_data.username)
+ if session.exec(statement).first():
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Username already taken"
+ )
+
+ # If this is the first user, make them admin
+ is_admin = False
+ statement = select(User)
+ if session.exec(statement).first() is None:
+ logger.info(f"First user registered, granting admin rights to {user_data.username}")
+ is_admin = True
+
+ # Create user
+ user = User(
+ email=user_data.email,
+ username=user_data.username,
+ hashed_password=hash_password(user_data.password),
+ full_name=user_data.full_name,
+ is_active=True,
+ is_verified=False,
+ is_superuser=is_admin
+ )
+
+ session.add(user)
+ session.commit()
+ session.refresh(user)
+
+ # Send verification email
+ create_and_send_verification_email(session, user)
+
+ return user
+
+
+def refresh_access_token(session: Session, refresh_token_str: str) -> dict:
+ """
+ Refresh access token using refresh token
+
+ Args:
+ session: Database session
+ refresh_token_str: Refresh token string
+
+ Returns:
+ Dictionary with new access_token and refresh_token
+
+ Raises:
+ HTTPException: If refresh token is invalid or expired
+ """
+ # Find refresh token
+ statement = select(RefreshToken).where(
+ RefreshToken.token == refresh_token_str,
+ RefreshToken.revoked.is_(False)
+ )
+ refresh_token = session.exec(statement).first()
+
+ if not refresh_token:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid refresh token"
+ )
+
+ # Check if expired
+ expires_at = ensure_timezone_aware(refresh_token.expires_at)
+ if expires_at < datetime.now(timezone.utc):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Refresh token expired"
+ )
+
+ # Get user
+ statement = select(User).where(User.username == refresh_token.username)
+ user = session.exec(statement).first()
+
+ if not user or not user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="User not found or inactive"
+ )
+
+ # Create new tokens
+ access_token = create_access_token({"sub": str(user.id)})
+ new_refresh_token = create_refresh_token(
+ session,
+ user.username,
+ refresh_token.device_info
+ )
+
+ # Revoke old refresh token (token rotation)
+ refresh_token.revoked = True
+ refresh_token.revoked_at = datetime.now(timezone.utc)
+ session.add(refresh_token)
+ session.commit()
+
+ settings = get_settings()
+ return {
+ "access_token": access_token,
+ "refresh_token": new_refresh_token.token,
+ "token_type": "bearer",
+ "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
+ }
+
+
+def revoke_refresh_token(session: Session, token_str: str) -> bool:
+ """
+ Revoke a refresh token (logout)
+
+ Args:
+ session: Database session
+ token_str: Refresh token to revoke
+
+ Returns:
+ True if token was revoked
+ """
+ statement = select(RefreshToken).where(RefreshToken.token == token_str)
+ token = session.exec(statement).first()
+
+ if token and not token.revoked:
+ token.revoked = True
+ token.revoked_at = datetime.now(timezone.utc)
+ session.add(token)
+ session.commit()
+ return True
+
+ return False
+
+
+def initiate_password_reset(session: Session, email: str) -> bool:
+ """
+ Initiate password reset process
+
+ Args:
+ session: Database session
+ email: User email
+
+ Returns:
+ True (always, to prevent email enumeration)
+ """
+ # Find user
+ statement = select(User).where(User.email == email)
+ user = session.exec(statement).first()
+
+ if user:
+ # Generate reset token
+ token_str = generate_secure_token()
+ expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
+
+ reset_token = PasswordResetToken(
+ username=user.username,
+ token=token_str,
+ expires_at=expires_at
+ )
+
+ session.add(reset_token)
+ session.commit()
+
+ # Send reset email
+ send_password_reset_email(
+ user.email, token_str, user.full_name or user.username
+ )
+
+ # Always return True to prevent email enumeration
+ return True
+
+
+def complete_password_reset(
+ session: Session, token_str: str, new_password: str
+) -> bool:
+ """
+ Complete password reset with token
+
+ Args:
+ session: Database session
+ token_str: Reset token
+ new_password: New password
+
+ Returns:
+ True if password was reset
+
+ Raises:
+ HTTPException: If token is invalid or expired
+ """
+ # Validate password strength
+ is_valid, error_msg = validate_password_strength(new_password)
+ if not is_valid:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=error_msg
+ )
+
+ # Find token
+ statement = select(PasswordResetToken).where(
+ PasswordResetToken.token == token_str,
+ PasswordResetToken.used.is_(False)
+ )
+ reset_token = session.exec(statement).first()
+
+ if not reset_token:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid or already used reset token"
+ )
+
+ # Check if expired
+ expires_at = ensure_timezone_aware(reset_token.expires_at)
+ if expires_at < datetime.now(timezone.utc):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Reset token has expired"
+ )
+
+ # Get user and update password
+ user = session.get(User, reset_token.username)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found"
+ )
+
+ user.hashed_password = hash_password(new_password)
+ user.updated_at = datetime.now(timezone.utc)
+
+ # Mark token as used
+ reset_token.used = True
+ reset_token.used_at = datetime.now(timezone.utc)
+
+ session.add(user)
+ session.add(reset_token)
+ session.commit()
+
+ return True
+
+
+def create_and_send_verification_email(session: Session, user: User) -> None:
+ """
+ Create verification token and send email
+
+ Args:
+ session: Database session
+ user: User to send verification to
+ """
+ token_str = generate_secure_token()
+ expires_at = datetime.now(timezone.utc) + timedelta(days=7)
+
+ verification_token = EmailVerificationToken(
+ username=user.username,
+ token=token_str,
+ expires_at=expires_at
+ )
+
+ session.add(verification_token)
+ session.commit()
+
+ send_verification_email(
+ user.email, token_str, user.full_name or user.username
+ )
+
+
+def verify_email(session: Session, token_str: str) -> bool:
+ """
+ Verify user email with token
+
+ Args:
+ session: Database session
+ token_str: Verification token
+
+ Returns:
+ True if email was verified
+
+ Raises:
+ HTTPException: If token is invalid or expired
+ """
+ statement = select(EmailVerificationToken).where(
+ EmailVerificationToken.token == token_str,
+ EmailVerificationToken.used.is_(False)
+ )
+ verification_token = session.exec(statement).first()
+
+ if not verification_token:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid or already used verification token"
+ )
+
+ expires_at = ensure_timezone_aware(verification_token.expires_at)
+ if expires_at < datetime.now(timezone.utc):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Verification token has expired"
+ )
+
+ statement = select(User).where(User.username == verification_token.username)
+ user = session.exec(statement).first()
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found"
+ )
+
+ user.is_verified = True
+ user.updated_at = datetime.now(timezone.utc)
+
+ verification_token.used = True
+ verification_token.used_at = datetime.now(timezone.utc)
+
+ session.add(user)
+ session.add(verification_token)
+ session.commit()
+
+ return True
+
+
+def update_last_login(session: Session, username: str) -> None:
+ """Update user's last login timestamp"""
+ user = session.get(User, username)
+ if user:
+ user.last_login = datetime.now(timezone.utc)
+ session.add(user)
+ session.commit()
+
+
+def increment_failed_login(session: Session, user: User) -> None:
+ """Increment failed login attempts and lock account if needed"""
+ settings = get_settings()
+ user.failed_login_attempts += 1
+
+ if user.failed_login_attempts >= settings.MAX_FAILED_LOGIN_ATTEMPTS:
+ user.locked_until = datetime.now(timezone.utc) + timedelta(
+ minutes=settings.ACCOUNT_LOCKOUT_DURATION_MINUTES
+ )
+
+ session.add(user)
+ session.commit()
+
+
+def reset_failed_login(session: Session, user: User) -> None:
+ """Reset failed login attempts"""
+ user.failed_login_attempts = 0
+ user.locked_until = None
+ session.add(user)
+ session.commit()
+
+
+def get_user_by_username(session: Session, username: str) -> User | None:
+ """Get user by username"""
+ statement = select(User).where(User.username == username)
+ return session.exec(statement).first()
+
+
+def get_user_by_email(session: Session, email: str) -> User | None:
+ """Get user by email"""
+ statement = select(User).where(User.email == email)
+ return session.exec(statement).first()
diff --git a/api/jobs/models.py b/api/jobs/models.py
index e2cbf2c..9d61d7f 100644
--- a/api/jobs/models.py
+++ b/api/jobs/models.py
@@ -3,7 +3,7 @@
"""
from typing import Optional
import uuid
-from datetime import datetime
+from datetime import datetime, timezone
from enum import Enum
from sqlmodel import SQLModel, Field
from pydantic import ConfigDict
@@ -28,7 +28,7 @@ class BatchJob(SQLModel, table=True):
name: str = Field(max_length=255)
command: str = Field(max_length=1000)
user: str = Field(max_length=100)
- submitted_on: datetime = Field(default_factory=datetime.utcnow)
+ submitted_on: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
aws_job_id: str | None = Field(default=None, max_length=255)
log_stream_name: str | None = Field(default=None, max_length=255)
status: JobStatus = Field(default=JobStatus.SUBMITTED)
diff --git a/api/manifest/routes.py b/api/manifest/routes.py
index 55e35be..65d14f9 100644
--- a/api/manifest/routes.py
+++ b/api/manifest/routes.py
@@ -79,11 +79,7 @@ def validate_manifest(
session: SessionDep,
s3_path: str = Query(
..., description="S3 path to the manifest CSV file to validate"
- ),
- valid: bool = Query(
- True,
- description="Mock validation result for testing (True=valid, False=invalid)"
- ),
+ )
) -> ManifestValidationResponse:
"""
Validate a manifest CSV file from S3.
@@ -95,10 +91,9 @@ def validate_manifest(
Args:
s3_path: S3 path to the manifest CSV file to validate
- valid: Mock parameter to simulate valid or invalid responses for testing
Returns:
ManifestValidationResponse with validation status and any errors found
"""
- result = services.validate_manifest_file(session, s3_path, valid)
+ result = services.validate_manifest_file(session, s3_path)
return result
diff --git a/api/manifest/services.py b/api/manifest/services.py
index 19d5517..6d51f22 100644
--- a/api/manifest/services.py
+++ b/api/manifest/services.py
@@ -2,11 +2,13 @@
Services for the Manifest API
"""
+import json
from fastapi import HTTPException, status, UploadFile
import boto3
from botocore.exceptions import NoCredentialsError, ClientError
from api.manifest.models import ManifestUploadResponse, ManifestValidationResponse
from api.settings.services import get_setting_value
+from core.config import get_settings
from core.deps import SessionDep
from core.logger import logger
@@ -220,7 +222,7 @@ def upload_manifest_file(
def validate_manifest_file(
session: SessionDep,
- s3_path: str, valid: bool = True
+ s3_path: str
) -> ManifestValidationResponse:
"""
Validate a manifest CSV file from S3.
@@ -228,7 +230,6 @@ def validate_manifest_file(
Args:
session: Database session
s3_path: S3 path to the manifest CSV file to validate
- valid: Mock parameter to simulate valid or invalid responses for testing
Returns:
ManifestValidationResponse with validation status and any errors found
@@ -240,51 +241,20 @@ def validate_manifest_file(
lambda_function_name = get_setting_value(session, "MANIFEST_VALIDATION_LAMBDA")
logger.info(f"Invoking Lambda function: {lambda_function_name} for manifest validation")
- # Placeholder for invoking the lambda function
- # response = invoke_lambda(lambda_function_name, payload={"s3_path": s3_path})
-
- if not valid:
- # Return mock validation errors for testing
- return ManifestValidationResponse(
- valid=False,
- message={
- "ManifestVersion": "Validated against manifest version: DTS12.1",
- "ExtraFields": (
- "See extra fields (info only): "
- "['VHYB', 'VLANE', 'VBARCODE']"
- )
- },
- error={
- "InvalidFilePath": [
- (
- "Unable to find file s3://example/example_1.clipped.fastq.gz "
- "described in row 182, check that file exists and is accessible"
- ),
- (
- "Unable to find file s3://example/example_2.clipped.fastq.gz "
- "described in row 183, check that file exists and is accessible"
- )
- ],
- "MissingRequiredField": [
- "Row 45 is missing required field 'SAMPLE_ID'",
- "Row 67 is missing required field 'FILE_PATH'"
- ],
- "InvalidDataFormat": [
- "Row 92: Invalid date format in field 'RUN_DATE', expected YYYY-MM-DD"
- ]
- },
- warning={
- "DuplicateSample": [
- "Sample 'ABC-123' appears multiple times in rows 10, 25, 42"
- ]
- }
- )
+ # Convert the payload dictionary to a JSON string and then to bytes
+ payload_bytes = bytes(json.dumps({"s3_path": s3_path}), encoding='utf8')
+
+ # Initialize the Lambda client
+ # Boto3 uses credentials from the environment or a configured profile
+ client = boto3.client('lambda', region_name=get_settings().AWS_REGION)
- return ManifestValidationResponse(
- valid=True,
- message={
- "ManifestVersion": "Validated against manifest version: DTS12.1"
- },
- error={},
- warning={}
+ # Invoke the function
+ response = client.invoke(
+ FunctionName=lambda_function_name,
+ InvocationType='RequestResponse', # Use 'Event' for async invocation
+ Payload=payload_bytes
)
+
+ # Read and decode the payload from the response
+ response_payload = json.loads(response['Payload'].read().decode('utf-8'))
+ return ManifestValidationResponse.model_validate(response_payload)
diff --git a/api/users/routes.py b/api/users/routes.py
deleted file mode 100644
index 03bb81a..0000000
--- a/api/users/routes.py
+++ /dev/null
@@ -1,55 +0,0 @@
-"""
-Routes/endpoints for the Users API
-"""
-
-from typing import Literal
-from fastapi import APIRouter, Query, status
-from core.deps import SessionDep
-from api.users.models import User, UserCreate, UserPublic, UsersPublic
-import api.users.services as services
-
-router = APIRouter(prefix="/users", tags=["User Endpoints"])
-
-
-@router.post(
- "",
- response_model=UserPublic,
- tags=["User Endpoints"],
- status_code=status.HTTP_201_CREATED,
-)
-def create_user(session: SessionDep, user_in: UserCreate) -> User:
- """
- Create a new user with optional attributes.
- """
- return services.create_user(session=session, user_in=user_in)
-
-
-@router.get("", response_model=UsersPublic, tags=["User Endpoints"])
-def get_users(
- session: SessionDep,
- page: int = Query(1, description="Page number (1-indexed)"),
- per_page: int = Query(20, description="Number of items per page"),
- sort_by: str = Query("user_id", description="Field to sort by"),
- sort_order: Literal["asc", "desc"] = Query(
- "asc", description="Sort order (asc or desc)"
- ),
-) -> UsersPublic:
- """
- Returns a paginated list of users.
- """
- return services.get_users(
- session=session,
- page=page,
- per_page=per_page,
- sort_by=sort_by,
- sort_order=sort_order,
- )
-
-
-@router.get("/{user_id}", response_model=UserPublic, tags=["User Endpoints"])
-def get_user_by_user_id(session: SessionDep, user_id: str) -> User:
- """
- Returns a single user by its user_id.
- Note: This is different from its internal "id".
- """
- return services.get_user_by_user_id(session=session, user_id=user_id)
diff --git a/core/config.py b/core/config.py
index cdedb6e..2ae5c2d 100644
--- a/core/config.py
+++ b/core/config.py
@@ -165,6 +165,187 @@ def AWS_REGION(self) -> str:
STORAGE_BACKEND: str = os.getenv("STORAGE_BACKEND", "s3")
STORAGE_ROOT_PATH: str = os.getenv("STORAGE_URI", "s3://my-storage-bucket")
+ # JWT Configuration
+ @computed_field
+ @property
+ def JWT_SECRET_KEY(self) -> str:
+ """Get JWT secret key from env or secrets"""
+ return self._get_config_value(
+ "JWT_SECRET_KEY",
+ default="change-this-secret-key-in-production"
+ )
+
+ @computed_field
+ @property
+ def JWT_ALGORITHM(self) -> str:
+ """Get JWT algorithm from env or secrets"""
+ return self._get_config_value("JWT_ALGORITHM", default="HS256")
+
+ @computed_field
+ @property
+ def ACCESS_TOKEN_EXPIRE_MINUTES(self) -> int:
+ """Get access token expiration in minutes"""
+ value = self._get_config_value("ACCESS_TOKEN_EXPIRE_MINUTES", default="30")
+ return int(value)
+
+ @computed_field
+ @property
+ def REFRESH_TOKEN_EXPIRE_DAYS(self) -> int:
+ """Get refresh token expiration in days"""
+ value = self._get_config_value("REFRESH_TOKEN_EXPIRE_DAYS", default="30")
+ return int(value)
+
+ # Password Policy
+ @computed_field
+ @property
+ def PASSWORD_MIN_LENGTH(self) -> int:
+ """Get minimum password length"""
+ value = self._get_config_value("PASSWORD_MIN_LENGTH", default="8")
+ return int(value)
+
+ @computed_field
+ @property
+ def PASSWORD_REQUIRE_UPPERCASE(self) -> bool:
+ """Check if password requires uppercase"""
+ value = self._get_config_value("PASSWORD_REQUIRE_UPPERCASE", default="true")
+ return value.lower() in ("true", "1", "yes")
+
+ @computed_field
+ @property
+ def PASSWORD_REQUIRE_LOWERCASE(self) -> bool:
+ """Check if password requires lowercase"""
+ value = self._get_config_value("PASSWORD_REQUIRE_LOWERCASE", default="true")
+ return value.lower() in ("true", "1", "yes")
+
+ @computed_field
+ @property
+ def PASSWORD_REQUIRE_DIGIT(self) -> bool:
+ """Check if password requires digit"""
+ value = self._get_config_value("PASSWORD_REQUIRE_DIGIT", default="true")
+ return value.lower() in ("true", "1", "yes")
+
+ @computed_field
+ @property
+ def PASSWORD_REQUIRE_SPECIAL(self) -> bool:
+ """Check if password requires special character"""
+ value = self._get_config_value("PASSWORD_REQUIRE_SPECIAL", default="false")
+ return value.lower() in ("true", "1", "yes")
+
+ # Account Lockout
+ @computed_field
+ @property
+ def MAX_FAILED_LOGIN_ATTEMPTS(self) -> int:
+ """Get max failed login attempts before lockout"""
+ value = self._get_config_value("MAX_FAILED_LOGIN_ATTEMPTS", default="5")
+ return int(value)
+
+ @computed_field
+ @property
+ def ACCOUNT_LOCKOUT_DURATION_MINUTES(self) -> int:
+ """Get account lockout duration in minutes"""
+ value = self._get_config_value("ACCOUNT_LOCKOUT_DURATION_MINUTES", default="30")
+ return int(value)
+
+ # Email Configuration
+ @computed_field
+ @property
+ def EMAIL_ENABLED(self) -> bool:
+ """Check if email is enabled"""
+ value = self._get_config_value("EMAIL_ENABLED", default="false")
+ return value.lower() in ("true", "1", "yes")
+
+ @computed_field
+ @property
+ def FROM_EMAIL(self) -> str:
+ """Get from email address"""
+ return self._get_config_value("FROM_EMAIL", default="noreply@example.com")
+
+ @computed_field
+ @property
+ def FROM_NAME(self) -> str:
+ """Get from name"""
+ return self._get_config_value("FROM_NAME", default="NGS360")
+
+ @computed_field
+ @property
+ def FRONTEND_URL(self) -> str:
+ """Get frontend URL"""
+ return self._get_config_value("FRONTEND_URL", default="http://localhost:3000")
+
+ @computed_field
+ @property
+ def MAIL_SERVER(self) -> str | None:
+ """Get mail server"""
+ return self._get_config_value("MAIL_SERVER")
+
+ @computed_field
+ @property
+ def MAIL_PORT(self) -> str | None:
+ """Get mail server port"""
+ return self._get_config_value("MAIL_PORT")
+
+ @computed_field
+ @property
+ def MAIL_USERNAME(self) -> str | None:
+ """Get mail username"""
+ return self._get_config_value("MAIL_USERNAME")
+
+ @computed_field
+ @property
+ def MAIL_PASSWORD(self) -> str | None:
+ """Get mail password"""
+ return self._get_config_value("MAIL_PASSWORD")
+
+ @computed_field
+ @property
+ def MAIL_USE_TLS(self) -> bool:
+ """Check if mail uses TLS"""
+ value = self._get_config_value("MAIL_USE_TLS", default="false")
+ return value.lower() in ("true", "1", "yes")
+
+ @computed_field
+ @property
+ def MAIL_ADMINS(self) -> str | None:
+ """Get mail admins"""
+ return self._get_config_value("MAIL_ADMINS")
+
+ # OAuth2 Configuration
+ @computed_field
+ @property
+ def OAUTH_GOOGLE_CLIENT_ID(self) -> str | None:
+ """Get Google OAuth client ID"""
+ return self._get_config_value("OAUTH_GOOGLE_CLIENT_ID")
+
+ @computed_field
+ @property
+ def OAUTH_GOOGLE_CLIENT_SECRET(self) -> str | None:
+ """Get Google OAuth client secret"""
+ return self._get_config_value("OAUTH_GOOGLE_CLIENT_SECRET")
+
+ @computed_field
+ @property
+ def OAUTH_GITHUB_CLIENT_ID(self) -> str | None:
+ """Get GitHub OAuth client ID"""
+ return self._get_config_value("OAUTH_GITHUB_CLIENT_ID")
+
+ @computed_field
+ @property
+ def OAUTH_GITHUB_CLIENT_SECRET(self) -> str | None:
+ """Get GitHub OAuth client secret"""
+ return self._get_config_value("OAUTH_GITHUB_CLIENT_SECRET")
+
+ @computed_field
+ @property
+ def OAUTH_MICROSOFT_CLIENT_ID(self) -> str | None:
+ """Get Microsoft OAuth client ID"""
+ return self._get_config_value("OAUTH_MICROSOFT_CLIENT_ID")
+
+ @computed_field
+ @property
+ def OAUTH_MICROSOFT_CLIENT_SECRET(self) -> str | None:
+ """Get Microsoft OAuth client secret"""
+ return self._get_config_value("OAUTH_MICROSOFT_CLIENT_SECRET")
+
# Read environment variables from .env file, if it exists
# extra='ignore' prevents validation errors from extra env vars
model_config = SettingsConfigDict(
diff --git a/core/email.py b/core/email.py
new file mode 100644
index 0000000..bb9081d
--- /dev/null
+++ b/core/email.py
@@ -0,0 +1,285 @@
+"""
+Email service for sending transactional emails
+"""
+import logging
+from core.config import get_settings
+
+logger = logging.getLogger(__name__)
+
+
+def send_password_reset_email(
+ email: str,
+ token: str,
+ user_name: str
+) -> bool:
+ """
+ Send password reset email to user
+
+ Args:
+ email: User email address
+ token: Password reset token
+ user_name: User's name
+
+ Returns:
+ True if email was sent successfully
+ """
+ settings = get_settings()
+
+ if not settings.EMAIL_ENABLED:
+ logger.warning(
+ f"Email disabled. Would send password reset to {email}"
+ )
+ return False
+
+ # Build reset URL
+ reset_url = f"{settings.FRONTEND_URL}/reset-password?token={token}"
+
+ subject = "Password Reset Request"
+ body = f"""
+Hello {user_name},
+
+You requested to reset your password for your NGS360 account.
+
+Click the link below to reset your password:
+{reset_url}
+
+This link will expire in 1 hour.
+
+If you didn't request this, please ignore this email.
+
+Best regards,
+The NGS360 Team
+"""
+
+ try:
+ _send_email(email, subject, body)
+ logger.info(f"Password reset email sent to {email}")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to send password reset email: {e}")
+ return False
+
+
+def send_verification_email(
+ email: str,
+ token: str,
+ user_name: str
+) -> bool:
+ """
+ Send email verification email to user
+
+ Args:
+ email: User email address
+ token: Email verification token
+ user_name: User's name
+
+ Returns:
+ True if email was sent successfully
+ """
+ settings = get_settings()
+
+ if not settings.EMAIL_ENABLED:
+ logger.warning(
+ f"Email disabled. Would send verification to {email}"
+ )
+ return False
+
+ # Build verification URL
+ verify_url = f"{settings.FRONTEND_URL}/verify-email?token={token}"
+
+ subject = "Verify Your Email Address"
+ body = f"""
+Hello {user_name},
+
+Welcome to NGS360! Please verify your email address to activate your account.
+
+Click the link below to verify your email:
+{verify_url}
+
+This link will expire in 7 days.
+
+If you didn't create this account, please ignore this email.
+
+Best regards,
+The NGS360 Team
+"""
+
+ try:
+ _send_email(email, subject, body)
+ logger.info(f"Verification email sent to {email}")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to send verification email: {e}")
+ return False
+
+
+def send_welcome_email(email: str, user_name: str) -> bool:
+ """
+ Send welcome email to new user
+
+ Args:
+ email: User email address
+ user_name: User's name
+
+ Returns:
+ True if email was sent successfully
+ """
+ settings = get_settings()
+
+ if not settings.EMAIL_ENABLED:
+ logger.warning(f"Email disabled. Would send welcome to {email}")
+ return False
+
+ subject = "Welcome to NGS360"
+ body = f"""
+Hello {user_name},
+
+Welcome to NGS360! Your account has been successfully created.
+
+You can now log in and start using our platform.
+
+If you have any questions, please don't hesitate to contact us.
+
+Best regards,
+The NGS360 Team
+"""
+
+ try:
+ _send_email(email, subject, body)
+ logger.info(f"Welcome email sent to {email}")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to send welcome email: {e}")
+ return False
+
+
+def _send_email_aws_ses(to_email: str, subject: str, body: str) -> None:
+ """
+ Internal function to send email using AWS SES
+
+ Args:
+ to_email: Recipient email address
+ subject: Email subject
+ body: Email body (plain text)
+
+ Raises:
+ Exception: If email sending fails
+ """
+ settings = get_settings()
+
+ try:
+ import boto3
+ from botocore.exceptions import ClientError
+
+ # Create SES client
+ ses_client = boto3.client(
+ 'ses',
+ region_name=settings.AWS_REGION,
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY
+ )
+
+ # Send email
+ response = ses_client.send_email(
+ Source=f"{settings.FROM_NAME} <{settings.FROM_EMAIL}>",
+ Destination={'ToAddresses': [to_email]},
+ Message={
+ 'Subject': {'Data': subject, 'Charset': 'UTF-8'},
+ 'Body': {
+ 'Text': {'Data': body, 'Charset': 'UTF-8'}
+ }
+ }
+ )
+
+ logger.debug(f"Email sent. Message ID: {response['MessageId']}")
+
+ except ClientError as e:
+ error_code = e.response['Error']['Code']
+ error_message = e.response['Error']['Message']
+ logger.error(f"SES error {error_code}: {error_message}")
+ raise Exception(f"Failed to send email: {error_message}")
+ except ImportError:
+ logger.error("boto3 not installed. Cannot send emails via SES.")
+ raise Exception("Email service not configured")
+ except Exception as e:
+ logger.error(f"Unexpected error sending email: {e}")
+ raise
+
+
+def _send_email(to_email: str, subject: str, body: str) -> None:
+ """
+ Internal function to send email using SMTP
+
+ Args:
+ to_email: Recipient email address
+ subject: Email subject
+ body: Email body (plain text)
+
+ Raises:
+ Exception: If email sending fails
+ """
+ import smtplib
+ from email.mime.text import MIMEText
+ from email.mime.multipart import MIMEMultipart
+ from email.utils import formataddr
+
+ settings = get_settings()
+
+ # Validate SMTP configuration
+ if not settings.MAIL_SERVER:
+ logger.error("MAIL_SERVER not configured")
+ raise Exception("Email service not configured: MAIL_SERVER is required")
+
+ if not settings.MAIL_PORT:
+ logger.error("MAIL_PORT not configured")
+ raise Exception("Email service not configured: MAIL_PORT is required")
+
+ try:
+ # Create message
+ msg = MIMEMultipart()
+ msg['From'] = formataddr((settings.FROM_NAME, settings.FROM_EMAIL))
+ msg['To'] = to_email
+ msg['Subject'] = subject
+
+ # Attach body as plain text
+ msg.attach(MIMEText(body, 'plain', 'utf-8'))
+
+ # Connect to SMTP server
+ mail_port = int(settings.MAIL_PORT)
+
+ logger.debug(f"Connecting to SMTP server {settings.MAIL_SERVER}:{mail_port}")
+
+ # Create SMTP connection
+ smtp_server = smtplib.SMTP(settings.MAIL_SERVER, mail_port, timeout=30)
+
+ try:
+ # Enable TLS if configured
+ if settings.MAIL_USE_TLS:
+ logger.debug("Starting TLS")
+ smtp_server.starttls()
+
+ # Authenticate if credentials provided
+ if settings.MAIL_USERNAME and settings.MAIL_PASSWORD:
+ logger.debug(f"Authenticating as {settings.MAIL_USERNAME}")
+ smtp_server.login(settings.MAIL_USERNAME, settings.MAIL_PASSWORD)
+
+ # Send email
+ smtp_server.send_message(msg)
+ logger.debug(f"Email sent successfully to {to_email}")
+
+ finally:
+ # Always close the connection
+ smtp_server.quit()
+
+ except smtplib.SMTPAuthenticationError as e:
+ logger.error(f"SMTP authentication failed: {e}")
+ raise Exception("Failed to send email: Authentication failed")
+ except smtplib.SMTPException as e:
+ logger.error(f"SMTP error: {e}")
+ raise Exception(f"Failed to send email: {str(e)}")
+ except ValueError as e:
+ logger.error(f"Invalid MAIL_PORT value: {e}")
+ raise Exception("Email service misconfigured: Invalid port number")
+ except Exception as e:
+ logger.error(f"Unexpected error sending email: {e}")
+ raise Exception(f"Failed to send email: {str(e)}")
diff --git a/core/security.py b/core/security.py
new file mode 100644
index 0000000..eae77dd
--- /dev/null
+++ b/core/security.py
@@ -0,0 +1,195 @@
+"""
+Security utilities for password hashing and JWT token management
+"""
+from datetime import datetime, timedelta, timezone
+from typing import Any
+import secrets
+
+import bcrypt
+from jose import jwt
+from sqlmodel import Session
+
+from core.config import get_settings
+from api.auth.models import RefreshToken
+
+# Bcrypt configuration
+BCRYPT_ROUNDS = 12
+
+
+def hash_password(password: str) -> str:
+ """
+ Hash a password using bcrypt
+
+ Args:
+ password: Plain text password
+
+ Returns:
+ Hashed password string
+ """
+ password_bytes = password.encode('utf-8')
+ salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
+ hashed = bcrypt.hashpw(password_bytes, salt)
+ return hashed.decode('utf-8')
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ """
+ Verify a password against its hash
+
+ Args:
+ plain_password: Plain text password to verify
+ hashed_password: Hashed password to compare against
+
+ Returns:
+ True if password matches, False otherwise
+ """
+ try:
+ password_bytes = plain_password.encode('utf-8')
+ hashed_bytes = hashed_password.encode('utf-8')
+ return bcrypt.checkpw(password_bytes, hashed_bytes)
+ except (ValueError, AttributeError):
+ return False
+
+
+def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
+ """
+ Create a JWT access token
+
+ Args:
+ data: Data to encode in the token (typically {"sub": user_id})
+ expires_delta: Optional custom expiration time
+
+ Returns:
+ Encoded JWT token string
+ """
+ settings = get_settings()
+ to_encode = data.copy()
+
+ if expires_delta:
+ expire = datetime.now(timezone.utc) + expires_delta
+ else:
+ expire = datetime.now(timezone.utc) + timedelta(
+ minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
+ )
+
+ to_encode.update({
+ "exp": expire,
+ "iat": datetime.now(timezone.utc),
+ "type": "access"
+ })
+
+ encoded_jwt = jwt.encode(
+ to_encode,
+ settings.JWT_SECRET_KEY,
+ algorithm=settings.JWT_ALGORITHM
+ )
+ return encoded_jwt
+
+
+def create_refresh_token(
+ session: Session,
+ username: str,
+ device_info: str | None = None
+) -> RefreshToken:
+ """
+ Create a refresh token and store in database
+
+ Args:
+ session: Database session
+ username: Username to create token for
+ device_info: Optional device/client information
+
+ Returns:
+ RefreshToken object
+ """
+ settings = get_settings()
+
+ # Generate secure random token
+ token_string = secrets.token_urlsafe(32)
+
+ # Calculate expiration
+ expires_at = datetime.now(timezone.utc) + timedelta(
+ days=settings.REFRESH_TOKEN_EXPIRE_DAYS
+ )
+
+ # Create token record
+ refresh_token = RefreshToken(
+ username=username,
+ token=token_string,
+ expires_at=expires_at,
+ device_info=device_info
+ )
+
+ session.add(refresh_token)
+ session.commit()
+ session.refresh(refresh_token)
+
+ return refresh_token
+
+
+def decode_token(token: str) -> dict[str, Any]:
+ """
+ Decode and validate a JWT token
+
+ Args:
+ token: JWT token string
+
+ Returns:
+ Decoded token payload
+
+ Raises:
+ JWTError: If token is invalid or expired
+ """
+ settings = get_settings()
+
+ payload = jwt.decode(
+ token,
+ settings.JWT_SECRET_KEY,
+ algorithms=[settings.JWT_ALGORITHM]
+ )
+ return payload
+
+
+def generate_secure_token(length: int = 32) -> str:
+ """
+ Generate a cryptographically secure random token
+
+ Args:
+ length: Number of bytes for token (default 32)
+
+ Returns:
+ URL-safe token string
+ """
+ return secrets.token_urlsafe(length)
+
+
+def validate_password_strength(password: str) -> tuple[bool, str | None]:
+ """
+ Validate password meets security requirements
+
+ Args:
+ password: Password to validate
+
+ Returns:
+ Tuple of (is_valid, error_message)
+ """
+ settings = get_settings()
+
+ if len(password) < settings.PASSWORD_MIN_LENGTH:
+ return False, f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters"
+
+ if settings.PASSWORD_REQUIRE_UPPERCASE and not any(c.isupper() for c in password):
+ return False, "Password must contain at least one uppercase letter"
+
+ if settings.PASSWORD_REQUIRE_LOWERCASE and not any(c.islower() for c in password):
+ return False, "Password must contain at least one lowercase letter"
+
+ if settings.PASSWORD_REQUIRE_DIGIT and not any(c.isdigit() for c in password):
+ return False, "Password must contain at least one digit"
+
+ if settings.PASSWORD_REQUIRE_SPECIAL:
+ special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
+ if not any(c in special_chars for c in password):
+ return False, "Password must contain at least one special character"
+
+ return True, None
diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md
new file mode 100644
index 0000000..0e50d38
--- /dev/null
+++ b/docs/AUTHENTICATION.md
@@ -0,0 +1,472 @@
+# Authentication System Documentation
+
+## Overview
+
+The NGS360 API now includes a comprehensive authentication system with:
+- Local username/password authentication
+- OAuth2 support for external providers (Google, GitHub, Microsoft)
+- JWT-based access tokens with refresh token rotation
+- Password reset via email
+- Email verification
+- Account security features (lockout, password policies)
+
+## Quick Start
+
+### 1. Install Dependencies
+
+```bash
+cd APIServer
+uv sync
+```
+
+### 2. Configure Environment Variables
+
+Create or update your `.env` file:
+
+```bash
+# JWT Configuration
+JWT_SECRET_KEY=your-super-secret-key-change-in-production
+JWT_ALGORITHM=HS256
+ACCESS_TOKEN_EXPIRE_MINUTES=30
+REFRESH_TOKEN_EXPIRE_DAYS=30
+
+# Password Policy
+PASSWORD_MIN_LENGTH=8
+PASSWORD_REQUIRE_UPPERCASE=true
+PASSWORD_REQUIRE_LOWERCASE=true
+PASSWORD_REQUIRE_DIGIT=true
+PASSWORD_REQUIRE_SPECIAL=false
+
+# Account Lockout
+MAX_FAILED_LOGIN_ATTEMPTS=5
+ACCOUNT_LOCKOUT_DURATION_MINUTES=30
+
+# Email Configuration (AWS SES)
+EMAIL_ENABLED=true
+FROM_EMAIL=noreply@yourdomain.com
+FROM_NAME=NGS360
+AWS_REGION=us-east-1
+
+# Frontend URL (for email links)
+FRONTEND_URL=http://localhost:3000
+
+# OAuth2 - Google (optional)
+OAUTH_GOOGLE_CLIENT_ID=your-google-client-id
+OAUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret
+
+# OAuth2 - GitHub (optional)
+OAUTH_GITHUB_CLIENT_ID=your-github-client-id
+OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
+
+# OAuth2 - Microsoft (optional)
+OAUTH_MICROSOFT_CLIENT_ID=your-microsoft-client-id
+OAUTH_MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret
+
+# Dev
+OAUTH_CLIENT_ID=ngsdev-360
+OAUTH_SECRETKEY=ngsdev-360-10102024
+OAUTH_URL=https://sso-qa.bms.com
+
+```
+
+### 3. Run Database Migration
+
+```bash
+alembic upgrade head
+```
+
+### 4. Start the Server
+
+```bash
+fastapi dev main.py
+```
+
+## API Endpoints
+
+### Authentication Endpoints
+
+#### Register New User
+```http
+POST /api/v1/auth/register
+Content-Type: application/json
+
+{
+ "email": "user@example.com",
+ "username": "johndoe",
+ "password": "SecurePass123",
+ "full_name": "John Doe"
+}
+```
+
+#### Login
+```http
+POST /api/v1/auth/login
+Content-Type: application/x-www-form-urlencoded
+
+username=user@example.com&password=SecurePass123
+```
+
+Response:
+```json
+{
+ "access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
+ "refresh_token": "random-secure-token",
+ "token_type": "bearer",
+ "expires_in": 1800
+}
+```
+
+#### Refresh Token
+```http
+POST /api/v1/auth/refresh
+Content-Type: application/json
+
+{
+ "refresh_token": "your-refresh-token"
+}
+```
+
+#### Logout
+```http
+POST /api/v1/auth/logout
+Content-Type: application/json
+
+{
+ "refresh_token": "your-refresh-token"
+}
+```
+
+#### Get Current User
+```http
+GET /api/v1/auth/me
+Authorization: Bearer your-access-token
+```
+
+#### Request Password Reset
+```http
+POST /api/v1/auth/password-reset/request
+Content-Type: application/json
+
+{
+ "email": "user@example.com"
+}
+```
+
+#### Confirm Password Reset
+```http
+POST /api/v1/auth/password-reset/confirm
+Content-Type: application/json
+
+{
+ "token": "reset-token-from-email",
+ "new_password": "NewSecurePass123"
+}
+```
+
+#### Change Password
+```http
+POST /api/v1/auth/password/change
+Authorization: Bearer your-access-token
+Content-Type: application/json
+
+{
+ "current_password": "OldPass123",
+ "new_password": "NewPass123"
+}
+```
+
+#### Verify Email
+```http
+POST /api/v1/auth/verify-email
+Content-Type: application/json
+
+{
+ "token": "verification-token-from-email"
+}
+```
+
+#### Resend Verification Email
+```http
+POST /api/v1/auth/resend-verification
+Content-Type: application/json
+
+{
+ "email": "user@example.com"
+}
+```
+
+### OAuth2 Endpoints
+
+#### Initiate OAuth2 Flow
+```http
+GET /api/v1/auth/oauth/{provider}/authorize
+```
+Where `{provider}` is one of: `google`, `github`, `microsoft`
+
+This redirects the user to the OAuth provider's authorization page.
+
+#### OAuth2 Callback
+```http
+GET /api/v1/auth/oauth/{provider}/callback?code=auth-code&state=state-value
+```
+
+This endpoint is called by the OAuth provider after user authorization.
+
+#### Link OAuth Provider to Account
+```http
+POST /api/v1/auth/oauth/{provider}/link
+Authorization: Bearer your-access-token
+Content-Type: application/json
+
+{
+ "code": "authorization-code"
+}
+```
+
+#### Unlink OAuth Provider
+```http
+DELETE /api/v1/auth/oauth/{provider}/unlink
+Authorization: Bearer your-access-token
+```
+
+## Protecting Endpoints
+
+To require authentication on your endpoints, use the authentication dependencies:
+
+```python
+from api.auth.deps import CurrentUser, CurrentActiveUser, CurrentSuperuser
+
+# Require any authenticated user
+@router.get("/protected")
+def protected_endpoint(current_user: CurrentUser):
+ return {"user": current_user.email}
+
+# Require active and verified user
+@router.get("/active-only")
+def active_only_endpoint(current_user: CurrentActiveUser):
+ return {"user": current_user.email}
+
+# Require superuser
+@router.get("/admin-only")
+def admin_only_endpoint(current_user: CurrentSuperuser):
+ return {"user": current_user.email}
+```
+
+## OAuth2 Provider Setup
+
+### Google OAuth2
+
+1. Go to [Google Cloud Console](https://console.cloud.google.com/)
+2. Create a new project or select existing
+3. Enable Google+ API
+4. Go to "Credentials" → "Create Credentials" → "OAuth 2.0 Client ID"
+5. Application type: Web application
+6. Authorized redirect URIs: `http://localhost:8000/api/v1/auth/oauth/google/callback`
+7. Copy Client ID and Client Secret to `.env`
+
+### GitHub OAuth2
+
+1. Go to GitHub Settings → Developer settings → OAuth Apps
+2. Click "New OAuth App"
+3. Application name: NGS360
+4. Homepage URL: `http://localhost:8000`
+5. Authorization callback URL: `http://localhost:8000/api/v1/auth/oauth/github/callback`
+6. Copy Client ID and Client Secret to `.env`
+
+### Microsoft OAuth2
+
+1. Go to [Azure Portal](https://portal.azure.com/)
+2. Navigate to "Azure Active Directory" → "App registrations"
+3. Click "New registration"
+4. Name: NGS360
+5. Supported account types: Accounts in any organizational directory and personal Microsoft accounts
+6. Redirect URI: Web - `http://localhost:8000/api/v1/auth/oauth/microsoft/callback`
+7. After creation, go to "Certificates & secrets" → "New client secret"
+8. Copy Application (client) ID and Client Secret to `.env`
+
+## Security Features
+
+### Password Policy
+
+Configurable password requirements:
+- Minimum length (default: 8)
+- Require uppercase letters (default: true)
+- Require lowercase letters (default: true)
+- Require digits (default: true)
+- Require special characters (default: false)
+
+### Account Lockout
+
+After a configurable number of failed login attempts (default: 5), accounts are temporarily locked for a specified duration (default: 30 minutes).
+
+### Token Security
+
+- **Access Tokens**: Short-lived JWT tokens (default: 30 minutes)
+- **Refresh Tokens**: Long-lived tokens stored in database (default: 30 days)
+- **Token Rotation**: Refresh tokens are rotated on each use
+- **Token Revocation**: Refresh tokens can be revoked (logout)
+
+### Email Verification
+
+New users must verify their email address before accessing protected resources. Verification tokens expire after 7 days.
+
+### Password Reset
+
+Password reset tokens are:
+- Single-use only
+- Expire after 1 hour
+- Cryptographically secure random strings
+
+## Testing
+
+### Manual Testing with curl
+
+Register a user:
+```bash
+curl -X POST http://localhost:8000/api/v1/auth/register \
+ -H "Content-Type: application/json" \
+ -d '{
+ "email": "test@example.com",
+ "username": "testuser",
+ "password": "TestPass123",
+ "full_name": "Test User"
+ }'
+```
+
+Login:
+```bash
+curl -X POST http://localhost:8000/api/v1/auth/login \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "username=test@example.com&password=TestPass123"
+```
+
+Access protected endpoint:
+```bash
+curl -X GET http://localhost:8000/api/v1/auth/me \
+ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
+```
+
+### Automated Tests
+
+Run the test suite:
+```bash
+pytest tests/api/test_auth.py -v
+```
+
+## Troubleshooting
+
+### Email Not Sending
+
+1. Check `EMAIL_ENABLED=true` in `.env`
+2. Verify AWS credentials are configured
+3. Ensure FROM_EMAIL is verified in AWS SES
+4. Check application logs for error messages
+
+### OAuth2 Not Working
+
+1. Verify client ID and secret are correct
+2. Check redirect URI matches exactly (including http/https)
+3. Ensure OAuth provider credentials are not expired
+4. Check that the provider is enabled in their console
+
+### Token Errors
+
+1. Ensure `JWT_SECRET_KEY` is set and consistent
+2. Check token expiration times
+3. Verify database connectivity for refresh tokens
+4. Clear expired tokens from database
+
+### Account Locked
+
+Wait for the lockout duration to expire, or manually reset in database:
+```sql
+UPDATE users SET failed_login_attempts = 0, locked_until = NULL
+WHERE email = 'user@example.com';
+```
+
+## Database Schema
+
+### Users Table
+- `id`: UUID primary key
+- `user_id`: Human-readable ID (U-YYYYMMDD-NNNN)
+- `email`: Unique email address
+- `username`: Unique username
+- `hashed_password`: Bcrypt hashed password (nullable for OAuth-only users)
+- `full_name`: User's full name
+- `is_active`: Account active flag
+- `is_verified`: Email verified flag
+- `is_superuser`: Superuser flag
+- `created_at`, `updated_at`, `last_login`: Timestamps
+- `failed_login_attempts`, `locked_until`: Security fields
+
+### Refresh Tokens Table
+- `id`: UUID primary key
+- `user_id`: Foreign key to users
+- `token`: Unique token string
+- `expires_at`: Expiration timestamp
+- `revoked`: Revocation flag
+- `device_info`: Optional device information
+
+### OAuth Providers Table
+- `id`: UUID primary key
+- `user_id`: Foreign key to users
+- `provider_name`: Provider enum (google, github, microsoft)
+- `provider_user_id`: User ID from provider
+- `access_token`, `refresh_token`: OAuth tokens
+- `token_expires_at`: Token expiration
+
+### Password Reset Tokens Table
+- `id`: UUID primary key
+- `user_id`: Foreign key to users
+- `token`: Unique reset token
+- `expires_at`: Expiration (1 hour)
+- `used`: Single-use flag
+
+### Email Verification Tokens Table
+- `id`: UUID primary key
+- `user_id`: Foreign key to users
+- `token`: Unique verification token
+- `expires_at`: Expiration (7 days)
+- `used`: Single-use flag
+
+## Production Deployment
+
+### Security Checklist
+
+- [ ] Change `JWT_SECRET_KEY` to a strong random value
+- [ ] Use HTTPS for all endpoints
+- [ ] Configure CORS properly
+- [ ] Set up rate limiting
+- [ ] Enable email verification requirement
+- [ ] Configure AWS SES for production
+- [ ] Set up monitoring and alerting
+- [ ] Review and adjust token expiration times
+- [ ] Enable database backups
+- [ ] Set up log aggregation
+
+### Environment Variables for Production
+
+```bash
+# Use strong random secret
+JWT_SECRET_KEY=$(openssl rand -hex 32)
+
+# Adjust token lifetimes as needed
+ACCESS_TOKEN_EXPIRE_MINUTES=15
+REFRESH_TOKEN_EXPIRE_DAYS=7
+
+# Enable all security features
+PASSWORD_REQUIRE_SPECIAL=true
+MAX_FAILED_LOGIN_ATTEMPTS=3
+
+# Production URLs
+FRONTEND_URL=https://yourdomain.com
+FROM_EMAIL=noreply@yourdomain.com
+```
+
+## Support
+
+For issues or questions:
+1. Check the troubleshooting section above
+2. Review the API documentation at `/docs`
+3. Check application logs
+4. Consult the implementation plan in `plans/authentication-implementation-plan.md`
diff --git a/docs/OAuth2_Authorization_Code_Grant_Flow.md b/docs/OAuth2_Authorization_Code_Grant_Flow.md
new file mode 100644
index 0000000..a294309
--- /dev/null
+++ b/docs/OAuth2_Authorization_Code_Grant_Flow.md
@@ -0,0 +1,99 @@
+# OAuth 2.0 Authorization Code Grant Flow
+
+## Flow Diagram
+
+```mermaid
+sequenceDiagram
+ participant User as Resource Owner (User)
+ participant Client as Client Application
+ participant AuthServer as Authorization Server
+ participant ResourceServer as Resource Server
+
+ Note over User,ResourceServer: OAuth 2.0 Authorization Code Grant Flow
+
+ User->>Client: 1. Initiates login/authorization
+
+ Client->>AuthServer: 2. Authorization Request
(client_id, redirect_uri, scope, state)
+
+ AuthServer->>User: 3. Display login & consent page
+
+ User->>AuthServer: 4. Authenticates & grants permission
+
+ AuthServer->>Client: 5. Redirect with Authorization Code
(code, state)
+
+ Note over Client: Client validates state parameter
+
+ Client->>AuthServer: 6. Token Request
(code, client_id, client_secret,
redirect_uri, grant_type)
+
+ AuthServer->>AuthServer: 7. Validates authorization code
and client credentials
+
+ AuthServer->>Client: 8. Access Token Response
(access_token, token_type,
expires_in, refresh_token)
+
+ Client->>ResourceServer: 9. API Request with Access Token
(Authorization: Bearer {access_token})
+
+ ResourceServer->>ResourceServer: 10. Validates access token
+
+ ResourceServer->>Client: 11. Protected Resource Data
+
+ Client->>User: 12. Display requested data
+```
+
+## Key Steps Explained
+
+### 1. Authorization Request
+Client redirects user to authorization server with parameters including:
+- `client_id`: Identifier for the client application
+- `redirect_uri`: Where to send the user after authorization
+- `scope`: Requested permissions
+- `state`: Random string for CSRF protection
+
+### 2. User Authentication
+Authorization server authenticates the user and requests consent for the requested permissions.
+
+### 3. Authorization Code Grant
+Upon approval, authorization server redirects back to client with an authorization code.
+
+### 4. Token Exchange
+Client exchanges the authorization code for an access token by making a back-channel request with client credentials. This request includes:
+- `code`: The authorization code received
+- `client_id`: Client identifier
+- `client_secret`: Client secret (confidential)
+- `redirect_uri`: Must match the original request
+- `grant_type`: Set to "authorization_code"
+
+### 5. Access Protected Resources
+Client uses the access token to make authenticated API requests to the resource server.
+
+## Security Features
+
+- **State parameter**: Prevents CSRF (Cross-Site Request Forgery) attacks by ensuring the response matches the request
+- **Authorization code**: Single-use, short-lived code exchanged over back-channel for enhanced security
+- **Client authentication**: Client must prove its identity when exchanging code for token
+- **Redirect URI validation**: Authorization server validates the redirect URI matches the registered value
+- **HTTPS requirement**: All communication should occur over secure HTTPS connections
+
+## Token Response
+
+The access token response typically includes:
+- `access_token`: The token used to access protected resources
+- `token_type`: Usually "Bearer"
+- `expires_in`: Token lifetime in seconds
+- `refresh_token`: (Optional) Used to obtain new access tokens without user interaction
+- `scope`: The actual scopes granted (may differ from requested)
+
+## Use Cases
+
+This flow is ideal for:
+- Web applications with server-side components
+- Applications that can securely store client credentials (confidential clients)
+- Scenarios requiring the highest level of security
+
+## Best Practices
+
+1. Always use HTTPS for all OAuth communications
+2. Implement and validate the `state` parameter
+3. Store client secrets securely and never expose them in client-side code
+4. Use short-lived authorization codes (typically 10 minutes or less)
+5. Implement proper token storage and handling on the client
+6. Validate all redirect URIs strictly
+7. Consider implementing PKCE (Proof Key for Code Exchange) for additional security
diff --git a/docs/SETUP.md b/docs/SETUP.md
new file mode 100644
index 0000000..bfa6c58
--- /dev/null
+++ b/docs/SETUP.md
@@ -0,0 +1,258 @@
+# Authentication Implementation - Quick Setup Guide
+
+## What Was Implemented
+
+The complete authentication system has been implemented with the following components:
+
+### Core Components
+1. **Database Models** ([`api/auth/models.py`](../api/auth/models.py))
+ - User, RefreshToken, OAuthProvider, PasswordResetToken, EmailVerificationToken
+ - Request/Response models for all auth operations
+
+2. **Security Utilities** ([`core/security.py`](../core/security.py))
+ - Password hashing with bcrypt
+ - JWT token generation and validation
+ - Password strength validation
+ - Secure token generation
+
+3. **Authentication Service** ([`api/auth/services.py`](../api/auth/services.py))
+ - User registration and authentication
+ - Token refresh and revocation
+ - Password reset flow
+ - Email verification
+
+4. **OAuth2 Service** ([`api/auth/oauth2_service.py`](../api/auth/oauth2_service.py))
+ - Support for Google, GitHub, Microsoft
+ - Account linking/unlinking
+ - User creation from OAuth data
+
+5. **Email Service** ([`core/email.py`](../core/email.py))
+ - Password reset emails
+ - Email verification emails
+ - AWS SES integration
+
+6. **Authentication Dependencies** ([`api/auth/deps.py`](../api/auth/deps.py))
+ - `CurrentUser` - Any authenticated user
+ - `CurrentActiveUser` - Active and verified user
+ - `CurrentSuperuser` - Superuser only
+ - `OptionalUser` - Optional authentication
+
+7. **API Routes**
+ - [`api/auth/routes.py`](../api/auth/routes.py) - Local auth endpoints
+ - [`api/auth/oauth_routes.py`](../api/auth/oauth_routes.py) - OAuth2 endpoints
+
+8. **Configuration** ([`core/config.py`](../core/config.py))
+ - JWT settings
+ - Password policy
+ - Account lockout
+ - Email configuration
+ - OAuth2 credentials
+
+9. **Database Migration** ([`alembic/versions/auth_001_add_authentication_tables.py`](../alembic/versions/auth_001_add_authentication_tables.py))
+ - Creates all authentication tables
+
+10. **Documentation** ([`docs/AUTHENTICATION.md`](AUTHENTICATION.md))
+ - Complete API documentation
+ - Setup guides
+ - Troubleshooting
+
+## Next Steps
+
+### 1. Install Dependencies
+
+```bash
+cd APIServer
+uv sync
+```
+
+This will install the new dependencies:
+- `passlib[bcrypt]` - Password hashing
+- `python-jose[cryptography]` - JWT tokens
+- `python-multipart` - Form data parsing
+- `email-validator` - Email validation
+- `authlib` - OAuth2 client
+- `httpx` - HTTP client for OAuth2
+
+### 2. Configure Environment
+
+Create or update `.env` file with minimum required settings:
+
+```bash
+# Required: JWT Secret (generate with: openssl rand -hex 32)
+JWT_SECRET_KEY=your-secret-key-here
+
+# Optional: Email (set to false to disable)
+EMAIL_ENABLED=false
+
+# Optional: Frontend URL (for email links)
+FRONTEND_URL=http://localhost:3000
+```
+
+For production, see full configuration in [`docs/AUTHENTICATION.md`](AUTHENTICATION.md).
+
+### 3. Run Database Migration
+
+```bash
+alembic upgrade head
+```
+
+This creates the authentication tables:
+- `users`
+- `refresh_tokens`
+- `oauth_providers`
+- `password_reset_tokens`
+- `email_verification_tokens`
+
+### 4. Start the Server
+
+```bash
+fastapi dev main.py
+```
+
+### 5. Test the API
+
+Visit http://localhost:8000/docs to see the interactive API documentation.
+
+#### Quick Test with curl
+
+Register a user:
+```bash
+curl -X POST http://localhost:8000/api/v1/auth/register \
+ -H "Content-Type: application/json" \
+ -d '{
+ "email": "test@example.com",
+ "username": "testuser",
+ "password": "TestPass123",
+ "full_name": "Test User"
+ }'
+```
+
+Login:
+```bash
+curl -X POST http://localhost:8000/api/v1/auth/login \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ -d "username=test@example.com&password=TestPass123"
+```
+
+## Available Endpoints
+
+### Authentication
+- `POST /api/v1/auth/register` - Register new user
+- `POST /api/v1/auth/login` - Login with email/password
+- `POST /api/v1/auth/refresh` - Refresh access token
+- `POST /api/v1/auth/logout` - Logout (revoke refresh token)
+- `GET /api/v1/auth/me` - Get current user info
+- `POST /api/v1/auth/password-reset/request` - Request password reset
+- `POST /api/v1/auth/password-reset/confirm` - Confirm password reset
+- `POST /api/v1/auth/password/change` - Change password
+- `POST /api/v1/auth/verify-email` - Verify email address
+- `POST /api/v1/auth/resend-verification` - Resend verification email
+
+### OAuth2
+- `GET /api/v1/auth/oauth/{provider}/authorize` - Start OAuth flow
+- `GET /api/v1/auth/oauth/{provider}/callback` - OAuth callback
+- `POST /api/v1/auth/oauth/{provider}/link` - Link OAuth account
+- `DELETE /api/v1/auth/oauth/{provider}/unlink` - Unlink OAuth account
+
+Where `{provider}` is: `google`, `github`, or `microsoft`
+
+## Protecting Your Endpoints
+
+To require authentication on existing endpoints, add the dependency:
+
+```python
+from api.auth.deps import CurrentActiveUser
+
+@router.get("/my-protected-endpoint")
+def my_endpoint(current_user: CurrentActiveUser):
+ # Only authenticated, active, verified users can access
+ return {"user_id": current_user.user_id}
+```
+
+## Files Created/Modified
+
+### New Files
+- `APIServer/api/auth/models.py` - Data models
+- `APIServer/api/auth/services.py` - Business logic
+- `APIServer/api/auth/routes.py` - Auth endpoints
+- `APIServer/api/auth/oauth_routes.py` - OAuth endpoints
+- `APIServer/api/auth/oauth2_service.py` - OAuth logic
+- `APIServer/api/auth/deps.py` - Auth dependencies
+- `APIServer/core/security.py` - Security utilities
+- `APIServer/core/email.py` - Email service
+- `APIServer/alembic/versions/auth_001_add_authentication_tables.py` - Migration
+- `APIServer/docs/AUTHENTICATION.md` - Full documentation
+- `plans/authentication-implementation-plan.md` - Implementation plan
+- `plans/authentication-code-examples.md` - Code examples
+
+### Modified Files
+- `APIServer/main.py` - Added auth routers
+- `APIServer/core/config.py` - Added auth configuration
+- `APIServer/pyproject.toml` - Added dependencies
+
+## Features
+
+### Security
+- ✅ Bcrypt password hashing
+- ✅ JWT access tokens (30 min default)
+- ✅ Refresh token rotation (30 days default)
+- ✅ Account lockout after failed attempts
+- ✅ Password strength validation
+- ✅ Email verification
+- ✅ Secure password reset
+
+### Authentication Methods
+- ✅ Local email/password
+- ✅ Google OAuth2
+- ✅ GitHub OAuth2
+- ✅ Microsoft OAuth2
+- ✅ Account linking (multiple auth methods per user)
+
+### User Management
+- ✅ User registration
+- ✅ Email verification
+- ✅ Password reset via email
+- ✅ Password change
+- ✅ User profile retrieval
+
+## Troubleshooting
+
+### Import Errors
+If you see import errors, run:
+```bash
+uv sync
+```
+
+### Database Errors
+If migration fails, check:
+1. Database connection in `.env`
+2. Previous migrations are up to date
+3. Database user has CREATE TABLE permissions
+
+### Email Not Sending
+If `EMAIL_ENABLED=false`, emails are logged but not sent. This is fine for development.
+
+For production email:
+1. Set `EMAIL_ENABLED=true`
+2. Configure AWS SES credentials
+3. Verify sender email in AWS SES
+
+### OAuth2 Not Working
+1. Verify client ID and secret in `.env`
+2. Check redirect URI matches exactly
+3. Ensure provider app is configured correctly
+
+## Next Steps (Optional)
+
+1. **Add Tests**: Create tests in `tests/api/test_auth.py`
+2. **Add Rate Limiting**: Implement rate limiting on auth endpoints
+3. **Add MFA**: Implement two-factor authentication
+4. **Add RBAC**: Implement role-based access control
+5. **Protect Endpoints**: Add authentication to existing endpoints
+
+## Support
+
+- Full documentation: [`docs/AUTHENTICATION.md`](AUTHENTICATION.md)
+- Implementation plan: [`plans/authentication-implementation-plan.md`](../../plans/authentication-implementation-plan.md)
+- Code examples: [`plans/authentication-code-examples.md`](../../plans/authentication-code-examples.md)
+- API docs: http://localhost:8000/docs (when server is running)
diff --git a/main.py b/main.py
index d9efa23..0a28fca 100644
--- a/main.py
+++ b/main.py
@@ -2,12 +2,16 @@
Main entrypoint for the FastAPI server
"""
-from fastapi import FastAPI
+from fastapi import FastAPI, Request, status
+from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from core.lifespan import lifespan
from core.config import get_settings
+from api.auth.routes import router as auth_router
+from api.auth.oauth_routes import router as oauth_router
from api.files.routes import router as files_router
from api.jobs.routes import router as jobs_router
from api.project.routes import router as project_router
@@ -30,6 +34,58 @@ def custom_generate_unique_id(route: APIRoute):
# Create schema & router
app = FastAPI(lifespan=lifespan, generate_unique_id_function=custom_generate_unique_id)
+
+# Generic validation error handler
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request: Request, exc: RequestValidationError):
+ """
+ Generic validation error handler that provides detailed, actionable error messages
+ for any endpoint with validation errors.
+ """
+ errors = exc.errors()
+
+ # Build a user-friendly error response
+ formatted_errors = []
+ for error in errors:
+ # Get the field path (e.g., ['body', 'email'] -> 'email')
+ field_path = " -> ".join(str(loc) for loc in error["loc"] if loc != "body")
+
+ formatted_error = {
+ "field": field_path or "body",
+ "message": error["msg"],
+ "type": error["type"],
+ }
+
+ # Add input value if available (helps debugging)
+ if "input" in error:
+ formatted_error["received"] = error.get("input")
+
+ formatted_errors.append(formatted_error)
+
+ # Determine if the entire body is missing
+ is_missing_body = any(
+ error["type"] == "missing" and "body" in error["loc"]
+ for error in errors
+ )
+
+ if is_missing_body:
+ message = "Request body is required but was not provided"
+ hint = f"Please send a JSON body with your request to {request.method} {request.url.path}"
+ else:
+ message = "Validation error in request"
+ hint = "Please check the errors below and correct your request"
+
+ return JSONResponse(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ content={
+ "detail": message,
+ "hint": hint,
+ "errors": formatted_errors,
+ "docs_url": f"{request.base_url}docs#{request.url.path.replace('/', '-').strip('-')}"
+ }
+ )
+
+
# CORS settings to allow client-server communication
# Set with env variable
origins = [get_settings().client_origin]
@@ -58,6 +114,11 @@ def health_check():
# Add each api/feature folder here
API_PREFIX = "/api/v1"
+# Authentication routers (no auth required)
+app.include_router(auth_router, prefix=API_PREFIX)
+app.include_router(oauth_router, prefix=API_PREFIX)
+
+# Feature routers
app.include_router(files_router, prefix=API_PREFIX)
app.include_router(jobs_router, prefix=API_PREFIX)
app.include_router(project_router, prefix=API_PREFIX)
diff --git a/pyproject.toml b/pyproject.toml
index 1564189..2169b15 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,16 +6,22 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"alembic>=1.16.4",
+ "authlib>=1.3.0",
+ "bcrypt>=4.0.0,<5.0.0",
"boto3>=1.40.30",
"cryptography>=45.0.6",
+ "email-validator>=2.1.0",
"fastapi[standard]>=0.116.1",
"gunicorn>=21.0.0",
+ "httpx>=0.27.0",
"mako>=1.3.10",
"opensearch-py>=3.0.0",
"pydantic>=2.11.7",
"pydantic-settings>=2.10.1",
"pymysql>=1.1.1",
"python-dotenv>=1.1.1",
+ "python-jose[cryptography]>=3.3.0",
+ "python-multipart>=0.0.6",
"pytz>=2025.2",
"sample-sheet>=0.13.0",
"smart-open>=7.3.1",
diff --git a/pytest.ini b/pytest.ini
index 134543e..ade8711 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -3,4 +3,6 @@ python_files = test_*.py
python_classes = Test*
python_functions = test_*
testpaths = tests
-pythonpath = .
\ No newline at end of file
+pythonpath = .
+filterwarnings =
+ ignore::ResourceWarning
\ No newline at end of file
diff --git a/tests/api/README_AUTH_TESTS.md b/tests/api/README_AUTH_TESTS.md
new file mode 100644
index 0000000..f8633de
--- /dev/null
+++ b/tests/api/README_AUTH_TESTS.md
@@ -0,0 +1,185 @@
+# Authentication Tests
+
+This directory contains comprehensive unit tests for the authentication system.
+
+## Running the Tests
+
+### Run all authentication tests:
+```bash
+cd APIServer
+pytest tests/api/test_auth.py -v
+```
+
+### Run with coverage:
+```bash
+pytest tests/api/test_auth.py -v --cov=api.auth --cov=core.security
+```
+
+### Run specific test class:
+```bash
+pytest tests/api/test_auth.py::TestUserRegistration -v
+pytest tests/api/test_auth.py::TestUserLogin -v
+pytest tests/api/test_auth.py::TestProtectedEndpoints -v
+```
+
+### Run specific test:
+```bash
+pytest tests/api/test_auth.py::TestUserRegistration::test_register_new_user -v
+```
+
+## Test Coverage
+
+### Test Classes
+
+1. **TestUserRegistration** - User registration functionality
+ - ✅ Successful registration
+ - ✅ Duplicate email prevention
+ - ✅ Duplicate username prevention
+ - ✅ Password strength validation
+
+2. **TestUserLogin** - User login functionality
+ - ✅ Successful login
+ - ✅ Wrong password handling
+ - ✅ Nonexistent user handling
+
+3. **TestTokenRefresh** - Token refresh functionality
+ - ✅ Successful token refresh
+ - ✅ Invalid token handling
+ - ✅ Token rotation (reuse prevention)
+
+4. **TestProtectedEndpoints** - Authentication on protected routes
+ - ✅ Access with valid token
+ - ✅ Access without token (401)
+ - ✅ Access with invalid token (401)
+
+5. **TestLogout** - Logout functionality
+ - ✅ Successful logout
+ - ✅ Token revocation
+
+6. **TestPasswordChange** - Password change functionality
+ - ✅ Successful password change
+ - ✅ Wrong current password handling
+
+7. **TestPasswordReset** - Password reset functionality
+ - ✅ Reset request
+ - ✅ Email enumeration prevention
+
+8. **TestAccountSecurity** - Security features
+ - ✅ Account lockout after failed attempts
+
+9. **TestSecurityUtilities** - Core security functions
+ - ✅ Password hashing
+ - ✅ Password verification
+ - ✅ Password strength validation
+ - ✅ JWT token creation and validation
+
+## Test Database
+
+Tests use an in-memory SQLite database that is:
+- Created fresh for each test session
+- Isolated from production data
+- Fast and doesn't require setup
+
+## Test Fixtures
+
+### `session`
+Provides a clean database session for each test.
+
+### `client`
+Provides a FastAPI TestClient with the test database.
+
+### `test_user`
+Creates a pre-registered test user with:
+- Email: testuser@example.com
+- Username: testuser
+- Password: TestPassword123
+- Status: Active and verified
+
+## Example Test Output
+
+```bash
+$ pytest tests/api/test_auth.py -v
+
+tests/api/test_auth.py::TestUserRegistration::test_register_new_user PASSED
+tests/api/test_auth.py::TestUserRegistration::test_register_duplicate_email PASSED
+tests/api/test_auth.py::TestUserRegistration::test_register_duplicate_username PASSED
+tests/api/test_auth.py::TestUserRegistration::test_register_weak_password PASSED
+tests/api/test_auth.py::TestUserLogin::test_login_success PASSED
+tests/api/test_auth.py::TestUserLogin::test_login_wrong_password PASSED
+tests/api/test_auth.py::TestUserLogin::test_login_nonexistent_user PASSED
+tests/api/test_auth.py::TestTokenRefresh::test_refresh_token_success PASSED
+tests/api/test_auth.py::TestTokenRefresh::test_refresh_invalid_token PASSED
+tests/api/test_auth.py::TestTokenRefresh::test_refresh_token_reuse_fails PASSED
+tests/api/test_auth.py::TestProtectedEndpoints::test_access_protected_endpoint_with_token PASSED
+tests/api/test_auth.py::TestProtectedEndpoints::test_access_protected_endpoint_without_token PASSED
+tests/api/test_auth.py::TestProtectedEndpoints::test_access_protected_endpoint_invalid_token PASSED
+tests/api/test_auth.py::TestLogout::test_logout_success PASSED
+tests/api/test_auth.py::TestPasswordChange::test_change_password_success PASSED
+tests/api/test_auth.py::TestPasswordChange::test_change_password_wrong_current PASSED
+tests/api/test_auth.py::TestPasswordReset::test_request_password_reset PASSED
+tests/api/test_auth.py::TestPasswordReset::test_request_password_reset_nonexistent_email PASSED
+tests/api/test_auth.py::TestAccountSecurity::test_account_lockout_after_failed_attempts PASSED
+tests/api/test_auth.py::TestSecurityUtilities::test_password_hashing PASSED
+tests/api/test_auth.py::TestSecurityUtilities::test_password_strength_validation PASSED
+tests/api/test_auth.py::TestSecurityUtilities::test_jwt_token_creation_and_validation PASSED
+
+======================== 22 passed in 2.34s ========================
+```
+
+## Adding New Tests
+
+When adding new authentication features, follow this pattern:
+
+```python
+class TestNewFeature:
+ """Test description"""
+
+ def test_success_case(self, client: TestClient, test_user):
+ """Test successful operation"""
+ response = client.post("/api/v1/auth/new-endpoint", json={...})
+ assert response.status_code == 200
+ # Add assertions
+
+ def test_failure_case(self, client: TestClient):
+ """Test error handling"""
+ response = client.post("/api/v1/auth/new-endpoint", json={...})
+ assert response.status_code == 400
+ # Add assertions
+```
+
+## Continuous Integration
+
+These tests should be run:
+- Before committing code
+- In CI/CD pipeline
+- Before deploying to production
+
+## Test Configuration
+
+Tests use the same configuration as the application but with:
+- In-memory database
+- Disabled email sending
+- Fast password hashing (for speed)
+
+## Troubleshooting
+
+### Tests fail with import errors
+```bash
+# Make sure you're in the APIServer directory
+cd APIServer
+# Install test dependencies
+uv sync --extra dev
+```
+
+### Tests fail with database errors
+```bash
+# The in-memory database should work automatically
+# If issues persist, check that SQLModel is installed
+uv sync
+```
+
+### Tests are slow
+```bash
+# Run tests in parallel (requires pytest-xdist)
+pytest tests/api/test_auth.py -n auto
+```
diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py
new file mode 100644
index 0000000..6f9ebde
--- /dev/null
+++ b/tests/api/test_auth.py
@@ -0,0 +1,500 @@
+"""
+Unit tests for authentication functionality
+"""
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session, create_engine, SQLModel
+from sqlmodel.pool import StaticPool
+
+from main import app
+from core.deps import get_db
+from core.security import hash_password
+from api.auth.models import User
+
+
+# Test database setup
+@pytest.fixture(name="session")
+def session_fixture():
+ """Create a test database session"""
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ SQLModel.metadata.create_all(engine)
+ with Session(engine) as session:
+ yield session
+
+
+@pytest.fixture(name="client")
+def client_fixture(session: Session):
+ """Create a test client with test database"""
+ def get_session_override():
+ return session
+
+ app.dependency_overrides[get_db] = get_session_override
+ client = TestClient(app)
+ yield client
+ app.dependency_overrides.clear()
+
+
+@pytest.fixture(name="test_user")
+def test_user_fixture(session: Session):
+ """Create a test user"""
+ user = User(
+ user_id="U-20260122-0001",
+ email="testuser@example.com",
+ username="testuser",
+ hashed_password=hash_password("TestPassword123"),
+ full_name="Test User",
+ is_active=True,
+ is_verified=True,
+ is_superuser=False
+ )
+ session.add(user)
+ session.commit()
+ session.refresh(user)
+ return user
+
+
+class TestUserRegistration:
+ """Test user registration functionality"""
+
+ def test_register_new_user(self, client: TestClient):
+ """Test successful user registration"""
+ response = client.post(
+ "/api/v1/auth/register",
+ json={
+ "email": "newuser@example.com",
+ "username": "newuser",
+ "password": "SecurePass123",
+ "full_name": "New User"
+ }
+ )
+ assert response.status_code == 201
+ data = response.json()
+ assert data["email"] == "newuser@example.com"
+ assert data["username"] == "newuser"
+ assert data["full_name"] == "New User"
+ assert data["is_active"] is True
+ assert data["is_verified"] is False # Email not verified yet
+
+ def test_register_duplicate_email(self, client: TestClient, test_user):
+ """Test registration with duplicate email fails"""
+ response = client.post(
+ "/api/v1/auth/register",
+ json={
+ "email": test_user.email,
+ "username": "differentuser",
+ "password": "SecurePass123"
+ }
+ )
+ assert response.status_code == 409
+ assert "Email already registered" in response.json()["detail"]
+
+ def test_register_duplicate_username(self, client: TestClient, test_user):
+ """Test registration with duplicate username fails"""
+ response = client.post(
+ "/api/v1/auth/register",
+ json={
+ "email": "different@example.com",
+ "username": test_user.username,
+ "password": "SecurePass123"
+ }
+ )
+ assert response.status_code == 409
+ assert "Username already taken" in response.json()["detail"]
+
+ def test_register_weak_password(self, client: TestClient):
+ """Test registration with weak password fails"""
+ response = client.post(
+ "/api/v1/auth/register",
+ json={
+ "email": "newuser@example.com",
+ "username": "newuser",
+ "password": "weak" # Too short
+ }
+ )
+ assert response.status_code == 400
+ assert "Password must be at least" in response.json()["detail"]
+
+
+class TestUserLogin:
+ """Test user login functionality"""
+
+ def test_login_success(self, client: TestClient, test_user):
+ """Test successful login"""
+ response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email, # username field contains email
+ "password": "TestPassword123"
+ }
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "access_token" in data
+ assert "refresh_token" in data
+ assert data["token_type"] == "bearer"
+ assert data["expires_in"] > 0
+
+ def test_login_wrong_password(self, client: TestClient, test_user):
+ """Test login with wrong password fails"""
+ response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "WrongPassword123"
+ }
+ )
+ assert response.status_code == 401
+ assert "Incorrect email or password" in response.json()["detail"]
+
+ def test_login_nonexistent_user(self, client: TestClient):
+ """Test login with nonexistent user fails"""
+ response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": "nonexistent@example.com",
+ "password": "SomePassword123"
+ }
+ )
+ assert response.status_code == 401
+
+
+class TestTokenRefresh:
+ """Test token refresh functionality"""
+
+ def test_refresh_token_success(self, client: TestClient, test_user):
+ """Test successful token refresh"""
+ # First login to get tokens
+ login_response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "TestPassword123"
+ }
+ )
+ refresh_token = login_response.json()["refresh_token"]
+
+ # Refresh the token
+ response = client.post(
+ "/api/v1/auth/refresh",
+ json={"refresh_token": refresh_token}
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "access_token" in data
+ assert "refresh_token" in data
+ # New refresh token should be different (token rotation)
+ assert data["refresh_token"] != refresh_token
+
+ def test_refresh_invalid_token(self, client: TestClient):
+ """Test refresh with invalid token fails"""
+ response = client.post(
+ "/api/v1/auth/refresh",
+ json={"refresh_token": "invalid-token"}
+ )
+ assert response.status_code == 401
+
+ def test_refresh_token_reuse_fails(self, client: TestClient, test_user):
+ """Test that refresh token cannot be reused"""
+ # Login and get refresh token
+ login_response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "TestPassword123"
+ }
+ )
+ refresh_token = login_response.json()["refresh_token"]
+
+ # Use refresh token once
+ client.post(
+ "/api/v1/auth/refresh",
+ json={"refresh_token": refresh_token}
+ )
+
+ # Try to use same token again (should fail)
+ response = client.post(
+ "/api/v1/auth/refresh",
+ json={"refresh_token": refresh_token}
+ )
+ assert response.status_code == 401
+
+
+class TestProtectedEndpoints:
+ """Test authentication on protected endpoints"""
+
+ def test_access_protected_endpoint_with_token(
+ self,
+ client: TestClient,
+ test_user
+ ):
+ """Test accessing protected endpoint with valid token"""
+ # Login to get access token
+ login_response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "TestPassword123"
+ }
+ )
+ access_token = login_response.json()["access_token"]
+
+ # Access protected endpoint
+ response = client.get(
+ "/api/v1/auth/me",
+ headers={"Authorization": f"Bearer {access_token}"}
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["email"] == test_user.email
+ assert data["username"] == test_user.username
+
+ def test_access_protected_endpoint_without_token(self, client: TestClient):
+ """Test accessing protected endpoint without token fails"""
+ response = client.get("/api/v1/auth/me")
+ assert response.status_code == 401
+
+ def test_access_protected_endpoint_invalid_token(
+ self,
+ client: TestClient
+ ):
+ """Test accessing protected endpoint with invalid token fails"""
+ response = client.get(
+ "/api/v1/auth/me",
+ headers={"Authorization": "Bearer invalid-token"}
+ )
+ assert response.status_code == 401
+
+
+class TestLogout:
+ """Test logout functionality"""
+
+ def test_logout_success(self, client: TestClient, test_user):
+ """Test successful logout"""
+ # Login first
+ login_response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "TestPassword123"
+ }
+ )
+ refresh_token = login_response.json()["refresh_token"]
+
+ # Logout
+ response = client.post(
+ "/api/v1/auth/logout",
+ json={"refresh_token": refresh_token}
+ )
+ assert response.status_code == 200
+ assert "Logged out successfully" in response.json()["message"]
+
+ # Try to use revoked refresh token (should fail)
+ refresh_response = client.post(
+ "/api/v1/auth/refresh",
+ json={"refresh_token": refresh_token}
+ )
+ assert refresh_response.status_code == 401
+
+
+class TestPasswordChange:
+ """Test password change functionality"""
+
+ def test_change_password_success(self, client: TestClient, test_user):
+ """Test successful password change"""
+ # Login to get access token
+ login_response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "TestPassword123"
+ }
+ )
+ access_token = login_response.json()["access_token"]
+
+ # Change password
+ response = client.post(
+ "/api/v1/auth/password/change",
+ headers={"Authorization": f"Bearer {access_token}"},
+ json={
+ "current_password": "TestPassword123",
+ "new_password": "NewPassword456"
+ }
+ )
+ assert response.status_code == 200
+
+ # Verify old password no longer works
+ old_login = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "TestPassword123"
+ }
+ )
+ assert old_login.status_code == 401
+
+ # Verify new password works
+ new_login = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "NewPassword456"
+ }
+ )
+ assert new_login.status_code == 200
+
+ def test_change_password_wrong_current(
+ self,
+ client: TestClient,
+ test_user
+ ):
+ """Test password change with wrong current password fails"""
+ # Login
+ login_response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "TestPassword123"
+ }
+ )
+ access_token = login_response.json()["access_token"]
+
+ # Try to change with wrong current password
+ response = client.post(
+ "/api/v1/auth/password/change",
+ headers={"Authorization": f"Bearer {access_token}"},
+ json={
+ "current_password": "WrongPassword",
+ "new_password": "NewPassword456"
+ }
+ )
+ assert response.status_code == 400
+ assert "Current password is incorrect" in response.json()["detail"]
+
+
+class TestPasswordReset:
+ """Test password reset functionality"""
+
+ def test_request_password_reset(self, client: TestClient, test_user):
+ """Test password reset request"""
+ response = client.post(
+ "/api/v1/auth/password-reset/request",
+ json={"email": test_user.email}
+ )
+ assert response.status_code == 200
+ # Should always return success to prevent email enumeration
+ assert "reset link" in response.json()["message"].lower()
+
+ def test_request_password_reset_nonexistent_email(
+ self,
+ client: TestClient
+ ):
+ """Test password reset for nonexistent email still returns success"""
+ response = client.post(
+ "/api/v1/auth/password-reset/request",
+ json={"email": "nonexistent@example.com"}
+ )
+ # Should return success to prevent email enumeration
+ assert response.status_code == 200
+
+
+class TestAccountSecurity:
+ """Test account security features"""
+
+ def test_account_lockout_after_failed_attempts(
+ self,
+ client: TestClient,
+ test_user
+ ):
+ """Test account locks after multiple failed login attempts"""
+ # Attempt multiple failed logins
+ for _ in range(6): # MAX_FAILED_LOGIN_ATTEMPTS is 5
+ client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "WrongPassword"
+ }
+ )
+
+ # Next attempt should indicate account is locked
+ response = client.post(
+ "/api/v1/auth/login",
+ data={
+ "username": test_user.email,
+ "password": "TestPassword123" # Even correct password
+ }
+ )
+ assert response.status_code == 423 # Locked
+ assert "locked" in response.json()["detail"].lower()
+
+
+class TestSecurityUtilities:
+ """Test security utility functions"""
+
+ def test_password_hashing(self):
+ """Test password hashing and verification"""
+ from core.security import hash_password, verify_password
+
+ password = "TestPassword123"
+ hashed = hash_password(password)
+
+ # Hash should be different from original
+ assert hashed != password
+
+ # Should verify correctly
+ assert verify_password(password, hashed) is True
+
+ # Wrong password should not verify
+ assert verify_password("WrongPassword", hashed) is False
+
+ def test_password_strength_validation(self):
+ """Test password strength validation"""
+ from core.security import validate_password_strength
+
+ # Valid password
+ is_valid, error = validate_password_strength("ValidPass123")
+ assert is_valid is True
+ assert error is None
+
+ # Too short
+ is_valid, error = validate_password_strength("Short1")
+ assert is_valid is False
+ assert "at least" in error.lower()
+
+ # No uppercase
+ is_valid, error = validate_password_strength("nouppercas123")
+ assert is_valid is False
+ assert "uppercase" in error.lower()
+
+ # No lowercase
+ is_valid, error = validate_password_strength("NOLOWERCASE123")
+ assert is_valid is False
+ assert "lowercase" in error.lower()
+
+ # No digit
+ is_valid, error = validate_password_strength("NoDigitPass")
+ assert is_valid is False
+ assert "digit" in error.lower()
+
+ def test_jwt_token_creation_and_validation(self):
+ """Test JWT token creation and decoding"""
+ from core.security import create_access_token, decode_token
+ import uuid
+
+ user_id = str(uuid.uuid4())
+ token = create_access_token({"sub": user_id})
+
+ # Token should be a string
+ assert isinstance(token, str)
+ assert len(token) > 0
+
+ # Should be able to decode
+ payload = decode_token(token)
+ assert payload["sub"] == user_id
+ assert "exp" in payload
+ assert "iat" in payload
+ assert payload["type"] == "access"
diff --git a/tests/api/test_manifest.py b/tests/api/test_manifest.py
index 69c4fba..1d92da8 100644
--- a/tests/api/test_manifest.py
+++ b/tests/api/test_manifest.py
@@ -3,6 +3,7 @@
"""
from datetime import datetime
+from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
@@ -637,10 +638,22 @@ def test_upload_manifest_missing_file(
class TestManifestValidation:
"""Test manifest validation endpoint"""
- def test_validate_manifest_valid(self, client: TestClient):
+ @patch("api.manifest.services.boto3.client")
+ def test_validate_manifest_valid(self, mock_boto_client, client: TestClient):
"""Test validation endpoint with valid manifest (mock)"""
+ # Mock Lambda response for valid manifest
+ mock_lambda = MagicMock()
+ valid_response_json = (
+ b'{"valid": true, "message": {"ManifestVersion": "1.0"}, '
+ b'"error": {}, "warning": {}}'
+ )
+ mock_lambda.invoke.return_value = {
+ "Payload": MagicMock(read=lambda: valid_response_json)
+ }
+ mock_boto_client.return_value = mock_lambda
+
response = client.post(
- "/api/v1/manifest/validate?s3_path=s3://test-bucket/manifest.csv&valid=true"
+ "/api/v1/manifest/validate?s3_path=s3://test-bucket/manifest.csv"
)
# Verify successful response
@@ -666,10 +679,29 @@ def test_validate_manifest_valid(self, client: TestClient):
# Should have manifest version message
assert "ManifestVersion" in data["message"]
- def test_validate_manifest_invalid(self, client: TestClient):
+ # Verify Lambda was invoked
+ mock_lambda.invoke.assert_called_once()
+
+ @patch("api.manifest.services.boto3.client")
+ def test_validate_manifest_invalid(self, mock_boto_client, client: TestClient):
"""Test validation endpoint with invalid manifest (mock)"""
+ # Mock Lambda response for invalid manifest
+ mock_lambda = MagicMock()
+ invalid_response_json = (
+ b'{"valid": false, "message": {"ManifestVersion": "1.0", '
+ b'"ExtraFields": "field1, field2"}, "error": '
+ b'{"InvalidFilePath": ["Invalid path at row 5"], '
+ b'"MissingRequiredField": ["sample_id missing at row 3"], '
+ b'"InvalidDataFormat": ["Invalid date format at row 2"]}, '
+ b'"warning": {"DuplicateSample": ["Duplicate sample ID: S001"]}}'
+ )
+ mock_lambda.invoke.return_value = {
+ "Payload": MagicMock(read=lambda: invalid_response_json)
+ }
+ mock_boto_client.return_value = mock_lambda
+
response = client.post(
- "/api/v1/manifest/validate?s3_path=s3://test-bucket/manifest.csv&valid=false"
+ "/api/v1/manifest/validate?s3_path=s3://test-bucket/manifest.csv"
)
# Verify successful response
@@ -710,8 +742,21 @@ def test_validate_manifest_invalid(self, client: TestClient):
assert "ManifestVersion" in data["message"]
assert "ExtraFields" in data["message"]
- def test_validate_manifest_default_valid(self, client: TestClient):
- """Test validation endpoint defaults to valid=true"""
+ # Verify Lambda was invoked
+ mock_lambda.invoke.assert_called_once()
+
+ @patch("api.manifest.services.boto3.client")
+ def test_validate_manifest_default_valid(self, mock_boto_client, client: TestClient):
+ """Test validation endpoint with default Lambda response"""
+ # Mock Lambda response
+ mock_lambda = MagicMock()
+ mock_lambda.invoke.return_value = {
+ "Payload": MagicMock(
+ read=lambda: b'{"valid": true, "message": {}, "error": {}, "warning": {}}'
+ )
+ }
+ mock_boto_client.return_value = mock_lambda
+
response = client.post(
"/api/v1/manifest/validate?s3_path=s3://test-bucket/manifest.csv"
)
@@ -721,7 +766,7 @@ def test_validate_manifest_default_valid(self, client: TestClient):
data = response.json()
- # Default should be valid
+ # Should process valid response
assert data["valid"] is True
def test_validate_manifest_missing_s3_path(self, client: TestClient):
@@ -731,17 +776,38 @@ def test_validate_manifest_missing_s3_path(self, client: TestClient):
# Verify 422 error (missing required parameter)
assert response.status_code == 422
- def test_validate_manifest_response_structure(self, client: TestClient):
+ @patch("api.manifest.services.boto3.client")
+ def test_validate_manifest_response_structure(self, mock_boto_client, client: TestClient):
"""Test that both valid and invalid responses match expected structure"""
+ # Mock Lambda for valid response
+ mock_lambda = MagicMock()
+ valid_json = (
+ b'{"valid": true, "message": {"ManifestVersion": "1.0"}, '
+ b'"error": {}, "warning": {}}'
+ )
+ mock_lambda.invoke.return_value = {
+ "Payload": MagicMock(read=lambda: valid_json)
+ }
+ mock_boto_client.return_value = mock_lambda
+
# Test valid response
valid_response = client.post(
- "/api/v1/manifest/validate?s3_path=s3://test-bucket/manifest.csv&valid=true"
+ "/api/v1/manifest/validate?s3_path=s3://test-bucket/manifest.csv"
)
valid_data = valid_response.json()
+ # Mock Lambda for invalid response
+ invalid_json = (
+ b'{"valid": false, "message": {"ManifestVersion": "1.0"}, '
+ b'"error": {"InvalidFilePath": ["Error"]}, "warning": {}}'
+ )
+ mock_lambda.invoke.return_value = {
+ "Payload": MagicMock(read=lambda: invalid_json)
+ }
+
# Test invalid response
invalid_response = client.post(
- "/api/v1/manifest/validate?s3_path=s3://test-bucket/manifest.csv&valid=false"
+ "/api/v1/manifest/validate?s3_path=s3://test-bucket/manifest.csv"
)
invalid_data = invalid_response.json()
diff --git a/tests/api/test_samples.py b/tests/api/test_samples.py
index c27dd15..29b61c2 100644
--- a/tests/api/test_samples.py
+++ b/tests/api/test_samples.py
@@ -209,7 +209,7 @@ def test_fail_to_add_sample_to_project(client: TestClient, session: Session):
f"/api/v1/projects/{new_project.project_id}/samples", json=sample_data
)
assert response.status_code == 422
- assert "Extra inputs are not permitted" in response.json()["detail"][0]["msg"]
+ assert "Extra inputs are not permitted" in response.json()["errors"][0]["message"]
def test_fail_to_add_sample_to_nonexistent_project(
diff --git a/tests/conftest.py b/tests/conftest.py
index 1c7928b..5db65fc 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -407,39 +407,60 @@ def reset_settings_cache():
def session_fixture():
"""Provide a fresh database session for each test"""
engine = create_engine(
- "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
+ "sqlite://",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ pool_pre_ping=True
)
- SQLModel.metadata.create_all(engine)
- with Session(engine) as session:
- # Seed test settings
- from api.settings.models import Setting
- test_settings = [
- Setting(
- key="DATA_BUCKET_URI",
- value="s3://test-data-bucket",
- name="Data Bucket URI",
- description="Test data bucket"
- ),
- Setting(
- key="RESULTS_BUCKET_URI",
- value="s3://test-results-bucket",
- name="Results Bucket URI",
- description="Test results bucket"
- ),
- Setting(
- key="DEMUX_WORKFLOW_CONFIGS_BUCKET_URI",
- value="s3://test-tool-configs-bucket",
- name="Demux Workflow Configs Bucket URI",
- description="Test demux workflow configs bucket"
- ),
- ]
- for setting in test_settings:
- session.add(setting)
- session.commit()
-
- yield session
- SQLModel.metadata.drop_all(engine)
- engine.dispose()
+ connection = engine.connect()
+ SQLModel.metadata.create_all(bind=connection)
+
+ session = Session(bind=connection, expire_on_commit=False)
+
+ # Seed test settings
+ from api.settings.models import Setting
+ test_settings = [
+ Setting(
+ key="DATA_BUCKET_URI",
+ value="s3://test-data-bucket",
+ name="Data Bucket URI",
+ description="Test data bucket"
+ ),
+ Setting(
+ key="RESULTS_BUCKET_URI",
+ value="s3://test-results-bucket",
+ name="Results Bucket URI",
+ description="Test results bucket"
+ ),
+ Setting(
+ key="DEMUX_WORKFLOW_CONFIGS_BUCKET_URI",
+ value="s3://test-tool-configs-bucket",
+ name="Demux Workflow Configs Bucket URI",
+ description="Test demux workflow configs bucket"
+ ),
+ Setting(
+ key="MANIFEST_VALIDATION_LAMBDA",
+ value="test-manifest-validation-lambda",
+ name="Manifest Validation Lambda",
+ description="Test Lambda function for manifest validation"
+ ),
+ ]
+ for setting in test_settings:
+ session.add(setting)
+ session.commit()
+
+ yield session
+
+ # Cleanup: properly close session, connection, and dispose engine
+ try:
+ session.rollback()
+ except Exception:
+ pass
+ finally:
+ session.close()
+ SQLModel.metadata.drop_all(bind=connection)
+ connection.close()
+ engine.dispose()
@pytest.fixture(name="mock_opensearch_client")
diff --git a/uv.lock b/uv.lock
index b6c5fa4..bd8713d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.13"
[[package]]
@@ -38,6 +38,68 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
+[[package]]
+name = "authlib"
+version = "1.6.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" },
+]
+
+[[package]]
+name = "bcrypt"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" },
+ { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" },
+ { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" },
+ { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" },
+ { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" },
+ { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" },
+ { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" },
+ { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" },
+ { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" },
+ { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" },
+ { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" },
+ { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" },
+ { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" },
+ { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" },
+]
+
[[package]]
name = "boto3"
version = "1.40.30"
@@ -237,6 +299,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
]
+[[package]]
+name = "ecdsa"
+version = "0.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
+]
+
[[package]]
name = "email-validator"
version = "2.2.0"
@@ -537,16 +611,22 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
+ { name = "authlib" },
+ { name = "bcrypt" },
{ name = "boto3" },
{ name = "cryptography" },
+ { name = "email-validator" },
{ name = "fastapi", extra = ["standard"] },
{ name = "gunicorn" },
+ { name = "httpx" },
{ name = "mako" },
{ name = "opensearch-py" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pymysql" },
{ name = "python-dotenv" },
+ { name = "python-jose", extra = ["cryptography"] },
+ { name = "python-multipart" },
{ name = "pytz" },
{ name = "sample-sheet" },
{ name = "smart-open" },
@@ -571,11 +651,15 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.16.4" },
+ { name = "authlib", specifier = ">=1.3.0" },
+ { name = "bcrypt", specifier = ">=4.0.0,<5.0.0" },
{ name = "boto3", specifier = ">=1.40.30" },
{ name = "cryptography", specifier = ">=45.0.6" },
+ { name = "email-validator", specifier = ">=2.1.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.116.1" },
{ name = "flake8", marker = "extra == 'dev'" },
{ name = "gunicorn", specifier = ">=21.0.0" },
+ { name = "httpx", specifier = ">=0.27.0" },
{ name = "mako", specifier = ">=1.3.10" },
{ name = "opensearch-py", specifier = ">=3.0.0" },
{ name = "pydantic", specifier = ">=2.11.7" },
@@ -584,6 +668,8 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "pytest-cov", marker = "extra == 'dev'" },
{ name = "python-dotenv", specifier = ">=1.1.1" },
+ { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
+ { name = "python-multipart", specifier = ">=0.0.6" },
{ name = "pytz", specifier = ">=2025.2" },
{ name = "sample-sheet", specifier = ">=0.13.0" },
{ name = "smart-open", specifier = ">=7.3.1" },
@@ -633,6 +719,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "pyasn1"
+version = "0.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
+]
+
[[package]]
name = "pycodestyle"
version = "2.14.0"
@@ -791,6 +886,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
+[[package]]
+name = "python-jose"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ecdsa" },
+ { name = "pyasn1" },
+ { name = "rsa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
+]
+
+[package.optional-dependencies]
+cryptography = [
+ { name = "cryptography" },
+]
+
[[package]]
name = "python-multipart"
version = "0.0.20"
@@ -900,6 +1014,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/9f/0f13511b27c3548372d9679637f1120e690370baf6ed890755eb73d9387b/rignore-0.6.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2252d603550d529362c569b10401ab32536613517e7e1df0e4477fe65498245", size = 974567, upload-time = "2025-07-13T11:57:26.592Z" },
]
+[[package]]
+name = "rsa"
+version = "4.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
+]
+
[[package]]
name = "s3transfer"
version = "0.14.0"