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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ COMPOSE_FILE := docker/docker-compose.yaml
COMPOSE_TEST_FILE := docker/docker-compose.test.yaml
COMPOSE_CLUSTER2_FILE := docker/docker-compose.cluster2.yaml

.PHONY: start stop restart build logs status test api-test portal-test setup-cluster setup-cluster2 clean help
.PHONY: start stop restart build logs status test api-test portal-test setup-cluster setup-cluster2 clean help lint api-lint portal-lint

help:
@echo "Tron Development Commands:"
Expand All @@ -20,6 +20,9 @@ help:
@echo " make setup-cluster2 - Setup k3s cluster2 with Tron (run after start)"
@echo " make clean - Stop services and remove volumes"
@echo " make build - Rebuild Docker images"
@echo " make lint - Run linters for API and Portal (same as pipeline)"
@echo " make api-lint - Run API linter (ruff check + format --check)"
@echo " make portal-lint - Run Portal linter (eslint + tsc --noEmit)"

start:
@echo "🚀 Starting Tron development environment..."
Expand Down Expand Up @@ -98,9 +101,30 @@ test:
@echo "✅ All tests completed!"
@echo "========================================="

# Lint (same checks as CI pipeline)
api-lint:
@echo "========================================="
@echo "🔍 Linting API (ruff check + format)..."
@echo "========================================="
@cd api && uv tool run ruff check app/ --output-format=github
@cd api && uv tool run ruff format app/ --check

portal-lint:
@echo "========================================="
@echo "🔍 Linting Portal (eslint + type check)..."
@echo "========================================="
@cd portal && npm run lint
@cd portal && npx tsc --noEmit

lint: api-lint portal-lint
@echo ""
@echo "========================================="
@echo "✅ Lint completed!"
@echo "========================================="

# Development helpers
api-migrate:
@$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) exec api alembic revision --autogenerate
@$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) exec api alembic upgrade head

api-shell:
@$(DOCKER_COMPOSE) -f $(COMPOSE_FILE) exec api sh
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""add identity_providers and user_social_accounts

Revision ID: add_idp_user_social
Revises: remove_role_from_tokens
Create Date: 2026-02-22

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision: str = "add_idp_user_social"
down_revision: Union[str, None] = "remove_role_from_tokens"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
conn = op.get_bind()
insp = sa.inspect(conn)
tables = insp.get_table_names()

if "identity_providers" not in tables:
op.create_table(
"identity_providers",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("uuid", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("slug", sa.String(64), nullable=False),
sa.Column("display_name", sa.String(255), nullable=False),
sa.Column("client_id", sa.String(512), nullable=False),
sa.Column("client_secret_encrypted", sa.Text(), nullable=True),
sa.Column("authorization_url", sa.String(1024), nullable=False),
sa.Column("token_url", sa.String(1024), nullable=False),
sa.Column("userinfo_url", sa.String(1024), nullable=True),
sa.Column("scopes", sa.String(512), nullable=False, server_default="openid email profile"),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("organization_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]),
)
op.create_index("ix_identity_providers_slug", "identity_providers", ["slug"], unique=True)
op.create_index("ix_identity_providers_uuid", "identity_providers", ["uuid"], unique=True)

if "user_social_accounts" not in tables:
op.create_table(
"user_social_accounts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("identity_provider_id", sa.Integer(), nullable=False),
sa.Column("provider_user_id", sa.String(255), nullable=False),
sa.Column("provider_email", sa.String(512), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["identity_provider_id"], ["identity_providers.id"], ondelete="CASCADE"),
sa.UniqueConstraint("identity_provider_id", "provider_user_id", name="uix_identity_provider_provider_user_id"),
)
op.create_index("ix_user_social_accounts_user_id", "user_social_accounts", ["user_id"])
op.create_index("ix_user_social_accounts_identity_provider_id", "user_social_accounts", ["identity_provider_id"])


def downgrade() -> None:
conn = op.get_bind()
insp = sa.inspect(conn)
tables = insp.get_table_names()
if "user_social_accounts" in tables:
op.drop_table("user_social_accounts")
if "identity_providers" in tables:
op.drop_table("identity_providers")
69 changes: 57 additions & 12 deletions api/app/auth/api/auth_handlers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from typing import Optional
import os

from app.shared.database.database import get_db
from app.users.infra.user_repository import UserRepository
Expand All @@ -27,6 +30,10 @@
InvalidCurrentPasswordError,
EmailAlreadyExistsError,
)
from app.auth.infra.identity_provider_repository import IdentityProviderRepository
from app.auth.infra.user_social_account_repository import UserSocialAccountRepository
from app.auth.core.identity_provider_service import IdentityProviderService
from app.auth.core.oauth_service import OAuthService


router = APIRouter(prefix="/auth", tags=["auth"])
Expand All @@ -39,6 +46,18 @@ def get_auth_service(database_session: Session = Depends(get_db)) -> AuthService
return AuthService(user_repository, token_repository)


def get_oauth_service(database_session: Session = Depends(get_db)) -> OAuthService:
"""Dependency for OAuth (social login) flow."""
secret_key = os.getenv(
"SECRET_KEY", "your-secret-key-change-in-production-minimum-32-characters"
)
idp_repo = IdentityProviderRepository(database_session)
idp_service = IdentityProviderService(idp_repo)
user_repo = UserRepository(database_session)
social_repo = UserSocialAccountRepository(database_session)
return OAuthService(idp_service, user_repo, social_repo, secret_key)


@router.post("/login", response_model=Token)
async def login(
login_data: LoginRequest, service: AuthService = Depends(get_auth_service)
Expand All @@ -52,7 +71,7 @@ async def login(
user = service.authenticate_user(login_data.email, login_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Email ou senha incorretos"
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password"
)

access_token = service.create_access_token(data={"sub": str(user.uuid)})
Expand All @@ -74,7 +93,7 @@ async def login_form(
user = service.authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Email ou senha incorretos"
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password"
)

access_token = service.create_access_token(data={"sub": str(user.uuid)})
Expand Down Expand Up @@ -115,15 +134,15 @@ async def refresh_token(
payload = service.verify_token(token_data.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token inválido"
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
)

user_uuid = payload.get("sub")
user = service.get_user_by_uuid(user_uuid)
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuário não encontrado ou inativo",
detail="User not found or inactive",
)

access_token = service.create_access_token(data={"sub": str(user.uuid)})
Expand Down Expand Up @@ -275,10 +294,36 @@ async def update_profile(
return current_user


@router.get("/google/login")
async def google_login():
"""Endpoint to initiate Google login."""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Login com Google será implementado em breve",
)
@router.get("/{slug}/login", include_in_schema=False)
async def oauth_login(
slug: str,
redirect_uri: Optional[str] = Query(None, description="Override callback URL"),
oauth_service: OAuthService = Depends(get_oauth_service),
auth_service: AuthService = Depends(get_auth_service),
):
"""Redirect to identity provider (e.g. Google) for login."""
api_public_url = redirect_uri or os.getenv(
"API_PUBLIC_URL", "http://localhost:8000"
).rstrip("/")
callback_url = f"{api_public_url}/auth/{slug}/callback"
url = oauth_service.build_authorization_url(slug, callback_url)
return RedirectResponse(url=url, status_code=302)


@router.get("/{slug}/callback", include_in_schema=False)
async def oauth_callback(
slug: str,
code: str = Query(...),
state: str = Query(...),
oauth_service: OAuthService = Depends(get_oauth_service),
auth_service: AuthService = Depends(get_auth_service),
):
"""OAuth callback: exchange code for user, issue JWT, redirect to frontend."""
api_public_url = os.getenv("API_PUBLIC_URL", "http://localhost:8000").rstrip("/")
redirect_uri = f"{api_public_url}/auth/{slug}/callback"
user = oauth_service.exchange_code_and_get_user(slug, code, state, redirect_uri)
access_token = auth_service.create_access_token(data={"sub": str(user.uuid)})
refresh_token = auth_service.create_refresh_token(data={"sub": str(user.uuid)})
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
redirect_to = f"{frontend_url}/login/callback?access_token={access_token}&refresh_token={refresh_token}"
return RedirectResponse(url=redirect_to, status_code=302)
91 changes: 91 additions & 0 deletions api/app/auth/api/identity_provider_dto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from datetime import datetime


class IdentityProviderBase(BaseModel):
slug: str = Field(..., min_length=1, max_length=64)
display_name: str = Field(..., min_length=1, max_length=255)
client_id: str = Field(..., min_length=1, max_length=512)
authorization_url: str = Field(..., min_length=1, max_length=1024)
token_url: str = Field(..., min_length=1, max_length=1024)
userinfo_url: Optional[str] = Field(None, max_length=1024)
scopes: str = Field(default="openid email profile", max_length=512)
is_enabled: bool = True
organization_id: Optional[int] = None

@field_validator(
"slug",
"display_name",
"client_id",
"authorization_url",
"token_url",
"userinfo_url",
"scopes",
mode="before",
)
@classmethod
def strip_strings(cls, v):
if v is not None and isinstance(v, str):
return v.strip() or v
return v


class IdentityProviderCreate(IdentityProviderBase):
client_secret: str = Field(..., min_length=1)


class IdentityProviderUpdate(BaseModel):
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
client_id: Optional[str] = Field(None, min_length=1, max_length=512)
client_secret: Optional[str] = Field(None, min_length=1)
authorization_url: Optional[str] = Field(None, min_length=1, max_length=1024)
token_url: Optional[str] = Field(None, min_length=1, max_length=1024)
userinfo_url: Optional[str] = Field(None, max_length=1024)
scopes: Optional[str] = Field(None, max_length=512)
is_enabled: Optional[bool] = None
organization_id: Optional[int] = None

@field_validator(
"display_name",
"client_id",
"client_secret",
"authorization_url",
"token_url",
"userinfo_url",
"scopes",
mode="before",
)
@classmethod
def strip_strings(cls, v):
if v is not None and isinstance(v, str):
s = v.strip()
return s if s else None
return v


class IdentityProviderResponse(BaseModel):
id: int
uuid: str
slug: str
display_name: str
client_id: str
client_secret_masked: Optional[str] = None
authorization_url: str
token_url: str
userinfo_url: Optional[str] = None
scopes: str
is_enabled: bool
organization_id: Optional[int] = None
created_at: datetime
updated_at: datetime

class Config:
from_attributes = True


class IdentityProviderPublic(BaseModel):
"""For login page: only slug and display_name."""

slug: str
display_name: str
Loading
Loading