diff --git a/Makefile b/Makefile index 614be23..5b1c8a1 100644 --- a/Makefile +++ b/Makefile @@ -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:" @@ -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..." @@ -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 diff --git a/api/alembic/versions/add_identity_providers_and_user_social_accounts.py b/api/alembic/versions/add_identity_providers_and_user_social_accounts.py new file mode 100644 index 0000000..d0152a3 --- /dev/null +++ b/api/alembic/versions/add_identity_providers_and_user_social_accounts.py @@ -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") diff --git a/api/app/auth/api/auth_handlers.py b/api/app/auth/api/auth_handlers.py index 161a5ef..72580a3 100644 --- a/api/app/auth/api/auth_handlers.py +++ b/api/app/auth/api/auth_handlers.py @@ -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 @@ -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"]) @@ -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) @@ -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)}) @@ -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)}) @@ -115,7 +134,7 @@ 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") @@ -123,7 +142,7 @@ async def refresh_token( 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)}) @@ -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) diff --git a/api/app/auth/api/identity_provider_dto.py b/api/app/auth/api/identity_provider_dto.py new file mode 100644 index 0000000..fc2eb3e --- /dev/null +++ b/api/app/auth/api/identity_provider_dto.py @@ -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 diff --git a/api/app/auth/api/identity_provider_handlers.py b/api/app/auth/api/identity_provider_handlers.py new file mode 100644 index 0000000..1f754f3 --- /dev/null +++ b/api/app/auth/api/identity_provider_handlers.py @@ -0,0 +1,155 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi.responses import Response +from sqlalchemy.orm import Session +from typing import List, Optional +from uuid import UUID + +from app.shared.database.database import get_db +from app.auth.infra.identity_provider_repository import IdentityProviderRepository +from app.auth.core.identity_provider_service import ( + IdentityProviderService, + IdentityProviderSlugAlreadyExistsError, + IdentityProviderNotFoundError, +) +from app.auth.api.identity_provider_dto import ( + IdentityProviderCreate, + IdentityProviderUpdate, + IdentityProviderResponse, + IdentityProviderPublic, +) +from app.users.infra.user_model import User, UserRole +from app.shared.dependencies.auth import require_role + + +router = APIRouter(tags=["identity-providers"]) + + +def get_identity_provider_service( + database_session: Session = Depends(get_db), +) -> IdentityProviderService: + repo = IdentityProviderRepository(database_session) + return IdentityProviderService(repo) + + +@router.get( + "/identity-providers", + response_model=List[IdentityProviderPublic], + summary="List enabled identity providers (for login page)", +) +async def list_enabled_providers( + enabled_only: bool = Query(True, description="Return only enabled providers"), + service: IdentityProviderService = Depends(get_identity_provider_service), +): + """Public endpoint: list identity providers available for login (e.g. Google, Microsoft).""" + return service.list_public_enabled() + + +@router.get( + "/admin/identity-providers", + response_model=List[IdentityProviderResponse], + summary="List all identity providers (admin)", +) +async def admin_list_providers( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + enabled_only: Optional[bool] = Query(None), + service: IdentityProviderService = Depends(get_identity_provider_service), + current_user: User = Depends(require_role([UserRole.ADMIN])), +): + """Admin only: list all identity providers with masked secrets.""" + return service.list( + skip=skip, + limit=limit, + enabled_only=enabled_only or False, + ) + + +@router.post( + "/admin/identity-providers", + response_model=IdentityProviderResponse, + status_code=status.HTTP_201_CREATED, + summary="Create identity provider (admin)", +) +async def admin_create_provider( + data: IdentityProviderCreate, + service: IdentityProviderService = Depends(get_identity_provider_service), + current_user: User = Depends(require_role([UserRole.ADMIN])), +): + """Admin only: create a new identity provider.""" + try: + return service.create(data) + except IdentityProviderSlugAlreadyExistsError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.options( + "/admin/identity-providers/{provider_uuid}", + include_in_schema=False, +) +async def admin_options_provider(provider_uuid: UUID): + """CORS preflight: return 200 without auth so browser can send PATCH.""" + return Response(status_code=200) + + +@router.get( + "/admin/identity-providers/{provider_uuid}", + response_model=IdentityProviderResponse, + summary="Get identity provider by UUID (admin)", +) +async def admin_get_provider( + provider_uuid: UUID, + service: IdentityProviderService = Depends(get_identity_provider_service), + current_user: User = Depends(require_role([UserRole.ADMIN])), +): + """Admin only: get one identity provider.""" + try: + return service.get_by_uuid(provider_uuid) + except IdentityProviderNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Identity provider not found", + ) + + +@router.patch( + "/admin/identity-providers/{provider_uuid}", + response_model=IdentityProviderResponse, + summary="Update identity provider (admin)", +) +async def admin_update_provider( + provider_uuid: UUID, + data: IdentityProviderUpdate, + service: IdentityProviderService = Depends(get_identity_provider_service), + current_user: User = Depends(require_role([UserRole.ADMIN])), +): + """Admin only: update identity provider. Send client_secret only to change it.""" + try: + return service.update(provider_uuid, data) + except IdentityProviderNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Identity provider not found", + ) + + +@router.delete( + "/admin/identity-providers/{provider_uuid}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete identity provider (admin)", +) +async def admin_delete_provider( + provider_uuid: UUID, + service: IdentityProviderService = Depends(get_identity_provider_service), + current_user: User = Depends(require_role([UserRole.ADMIN])), +): + """Admin only: delete identity provider.""" + try: + service.delete(provider_uuid) + except IdentityProviderNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Identity provider not found", + ) diff --git a/api/app/auth/api/token_handlers.py b/api/app/auth/api/token_handlers.py index 3cedb4f..f4d5570 100644 --- a/api/app/auth/api/token_handlers.py +++ b/api/app/auth/api/token_handlers.py @@ -43,7 +43,7 @@ async def list_tokens( service: TokenService = Depends(get_token_service), current_user: User = Depends(require_role([UserRole.ADMIN])), ): - """Lista todos os tokens (apenas admin)""" + """List all tokens (admin only).""" return service.list_tokens(skip=skip, limit=limit, search=search) @@ -53,7 +53,7 @@ async def get_token( service: TokenService = Depends(get_token_service), current_user: User = Depends(require_role([UserRole.ADMIN])), ): - """Busca um token por UUID (apenas admin)""" + """Get token by UUID (admin only).""" try: return service.get_token(token_uuid) except TokenNotFoundError as e: @@ -68,7 +68,7 @@ async def create_token( service: TokenService = Depends(get_token_service), current_user: User = Depends(require_role([UserRole.ADMIN])), ): - """Cria um novo token (apenas admin)""" + """Create a new token (admin only).""" user_id = current_user.id if hasattr(current_user, "id") else None return service.create_token(token_data, user_id) @@ -80,7 +80,7 @@ async def update_token( service: TokenService = Depends(get_token_service), current_user: User = Depends(require_role([UserRole.ADMIN])), ): - """Atualiza um token (apenas admin)""" + """Update a token (admin only).""" try: return service.update_token(token_uuid, token_data) except TokenNotFoundError as e: @@ -93,7 +93,7 @@ async def delete_token( service: TokenService = Depends(get_token_service), current_user: User = Depends(require_role([UserRole.ADMIN])), ): - """Deleta um token (apenas admin)""" + """Delete a token (admin only).""" try: service.delete_token(token_uuid) except TokenNotFoundError as e: diff --git a/api/app/auth/core/auth_service.py b/api/app/auth/core/auth_service.py index 5f26339..23bc5c6 100644 --- a/api/app/auth/core/auth_service.py +++ b/api/app/auth/core/auth_service.py @@ -79,7 +79,7 @@ def verify_token(token: str) -> dict: except JWTError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token inválido ou expirado", + detail="Invalid or expired token", ) def authenticate_user(self, email: str, password: str) -> Optional[User]: diff --git a/api/app/auth/core/identity_provider_service.py b/api/app/auth/core/identity_provider_service.py new file mode 100644 index 0000000..a4e789e --- /dev/null +++ b/api/app/auth/core/identity_provider_service.py @@ -0,0 +1,152 @@ +from uuid import UUID +from typing import Optional, List + +from app.auth.infra.identity_provider_model import IdentityProvider +from app.auth.infra.identity_provider_repository import IdentityProviderRepository +from app.auth.api.identity_provider_dto import ( + IdentityProviderCreate, + IdentityProviderUpdate, + IdentityProviderResponse, + IdentityProviderPublic, +) +from app.shared.crypto.secrets_crypto import ( + encrypt_secret, + decrypt_secret, + mask_secret_value, +) + + +class IdentityProviderSlugAlreadyExistsError(Exception): + def __init__(self, slug: str): + self.slug = slug + super().__init__(f"Identity provider with slug '{slug}' already exists.") + + +class IdentityProviderNotFoundError(Exception): + pass + + +class IdentityProviderService: + def __init__(self, repository: IdentityProviderRepository): + self.repository = repository + + def _model_to_response(self, model: IdentityProvider) -> IdentityProviderResponse: + client_secret_masked = None + if model.client_secret_encrypted: + try: + plain = decrypt_secret(model.client_secret_encrypted) + client_secret_masked = mask_secret_value(plain) + except Exception: + client_secret_masked = "********" + return IdentityProviderResponse( + id=model.id, + uuid=str(model.uuid), + slug=model.slug, + display_name=model.display_name, + client_id=model.client_id, + client_secret_masked=client_secret_masked, + authorization_url=model.authorization_url, + token_url=model.token_url, + userinfo_url=model.userinfo_url, + scopes=model.scopes, + is_enabled=model.is_enabled, + organization_id=model.organization_id, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + def list( + self, + skip: int = 0, + limit: int = 100, + enabled_only: bool = False, + organization_id: Optional[int] = None, + ) -> List[IdentityProviderResponse]: + items = self.repository.find_all( + skip=skip, + limit=limit, + enabled_only=enabled_only, + organization_id=organization_id, + ) + return [self._model_to_response(m) for m in items] + + def list_public_enabled(self) -> List[IdentityProviderPublic]: + """For login page: only enabled providers, slug + display_name.""" + items = self.repository.find_all(skip=0, limit=50, enabled_only=True) + return [ + IdentityProviderPublic(slug=m.slug, display_name=m.display_name) + for m in items + ] + + def get_by_id(self, id: int) -> IdentityProviderResponse: + model = self.repository.find_by_id(id) + if not model: + raise IdentityProviderNotFoundError() + return self._model_to_response(model) + + def get_by_uuid(self, uuid: UUID) -> IdentityProviderResponse: + model = self.repository.find_by_uuid(uuid) + if not model: + raise IdentityProviderNotFoundError() + return self._model_to_response(model) + + def get_by_slug(self, slug: str) -> Optional[IdentityProvider]: + """Returns raw model for OAuth flow (needs decrypted secret).""" + return self.repository.find_by_slug(slug) + + def get_client_secret_plain(self, provider: IdentityProvider) -> Optional[str]: + if not provider.client_secret_encrypted: + return None + return decrypt_secret(provider.client_secret_encrypted) + + def create(self, data: IdentityProviderCreate) -> IdentityProviderResponse: + if self.repository.find_by_slug(data.slug): + raise IdentityProviderSlugAlreadyExistsError(data.slug) + encrypted = encrypt_secret(data.client_secret) if data.client_secret else None + model = IdentityProvider( + slug=data.slug, + display_name=data.display_name, + client_id=data.client_id, + client_secret_encrypted=encrypted, + authorization_url=data.authorization_url, + token_url=data.token_url, + userinfo_url=data.userinfo_url, + scopes=data.scopes, + is_enabled=data.is_enabled, + organization_id=data.organization_id, + ) + model = self.repository.create(model) + return self._model_to_response(model) + + def update( + self, uuid: UUID, data: IdentityProviderUpdate + ) -> IdentityProviderResponse: + model = self.repository.find_by_uuid(uuid) + if not model: + raise IdentityProviderNotFoundError() + if data.display_name is not None: + model.display_name = data.display_name + if data.client_id is not None: + model.client_id = data.client_id + if data.client_secret is not None: + model.client_secret_encrypted = encrypt_secret(data.client_secret) + if data.authorization_url is not None: + model.authorization_url = data.authorization_url + if data.token_url is not None: + model.token_url = data.token_url + if data.userinfo_url is not None: + model.userinfo_url = data.userinfo_url + if data.scopes is not None: + model.scopes = data.scopes + if data.is_enabled is not None: + model.is_enabled = data.is_enabled + if data.organization_id is not None: + model.organization_id = data.organization_id + model = self.repository.update(model) + return self._model_to_response(model) + + def delete(self, uuid: UUID) -> None: + model = self.repository.find_by_uuid(uuid) + if not model: + raise IdentityProviderNotFoundError() + self.repository.delete(model) diff --git a/api/app/auth/core/oauth_service.py b/api/app/auth/core/oauth_service.py new file mode 100644 index 0000000..0c59956 --- /dev/null +++ b/api/app/auth/core/oauth_service.py @@ -0,0 +1,249 @@ +import hmac +import hashlib +import base64 +import time +from urllib.parse import urlencode +from typing import Optional + +import httpx +from fastapi import HTTPException, status + +from app.auth.infra.identity_provider_model import IdentityProvider +from app.auth.core.identity_provider_service import IdentityProviderService +from app.auth.infra.user_social_account_model import UserSocialAccount +from app.auth.infra.user_social_account_repository import UserSocialAccountRepository +from app.users.infra.user_model import User +from app.users.infra.user_repository import UserRepository + + +def _make_state(slug: str, secret_key: str) -> str: + """Create signed state parameter for CSRF protection (valid ~10 min).""" + raw = f"{slug}|{int(time.time())}" + sig = hmac.new( + secret_key.encode() if isinstance(secret_key, str) else secret_key, + raw.encode(), + hashlib.sha256, + ).hexdigest() + payload = f"{raw}|{sig}" + return base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=") + + +def _verify_state( + state: str, slug: str, secret_key: str, max_age_seconds: int = 600 +) -> bool: + try: + padding = 4 - len(state) % 4 + if padding != 4: + state += "=" * padding + payload = base64.urlsafe_b64decode(state.encode()).decode() + parts = payload.rsplit("|", 1) + if len(parts) != 2: + return False + raw, sig = parts + expected_sig = hmac.new( + secret_key.encode() if isinstance(secret_key, str) else secret_key, + raw.encode(), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(sig, expected_sig): + return False + slug_part, ts_part = raw.split("|") + if slug_part != slug: + return False + ts = int(ts_part) + return time.time() - ts <= max_age_seconds + except Exception: + return False + + +class OAuthService: + def __init__( + self, + identity_provider_service: IdentityProviderService, + user_repository: UserRepository, + user_social_account_repository: UserSocialAccountRepository, + secret_key: str, + ): + self.idp_service = identity_provider_service + self.user_repository = user_repository + self.social_repository = user_social_account_repository + self.secret_key = secret_key + + def build_authorization_url( + self, + slug: str, + redirect_uri: str, + ) -> str: + provider = self.idp_service.get_by_slug(slug) + if not provider or not provider.is_enabled: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Identity provider not found or disabled", + ) + state = _make_state(slug, self.secret_key) + params = { + "response_type": "code", + "client_id": provider.client_id, + "redirect_uri": redirect_uri, + "scope": provider.scopes, + "state": state, + } + return ( + provider.authorization_url + + ("&" if "?" in provider.authorization_url else "?") + + urlencode(params) + ) + + def exchange_code_and_get_user( + self, + slug: str, + code: str, + state: str, + redirect_uri: str, + ) -> User: + if not _verify_state(state, slug, self.secret_key): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired state. Please try signing in again.", + ) + provider = self.idp_service.get_by_slug(slug) + if not provider or not provider.is_enabled: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Identity provider not found or disabled", + ) + client_secret = self.idp_service.get_client_secret_plain(provider) + if not client_secret: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Identity provider is not configured correctly", + ) + # Exchange code for tokens + token_data = self._exchange_code(provider, code, redirect_uri, client_secret) + access_token = token_data.get("access_token") + if not access_token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provider response missing access_token", + ) + # Get user info + userinfo = self._get_userinfo(provider, access_token) + provider_user_id = userinfo.get("sub") or userinfo.get("id") + if not provider_user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provider did not return user identifier", + ) + email = (userinfo.get("email") or "").strip() or None + name = (userinfo.get("name") or userinfo.get("full_name") or "").strip() or None + picture = ( + userinfo.get("picture") or userinfo.get("avatar_url") or "" + ).strip() or None + return self._find_or_create_user_and_link( + provider=provider, + provider_user_id=str(provider_user_id), + email=email, + full_name=name, + avatar_url=picture, + ) + + def _exchange_code( + self, + provider: IdentityProvider, + code: str, + redirect_uri: str, + client_secret: str, + ) -> dict: + body = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": provider.client_id, + "client_secret": client_secret, + } + headers = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + } + with httpx.Client() as client: + resp = client.post( + provider.token_url, + data=body, + headers=headers, + timeout=15.0, + ) + if resp.status_code != 200: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Error exchanging code for token: {resp.text[:200]}", + ) + return resp.json() + + def _get_userinfo(self, provider: IdentityProvider, access_token: str) -> dict: + if not provider.userinfo_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Identity provider has no userinfo_url configured", + ) + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + } + with httpx.Client() as client: + resp = client.get( + provider.userinfo_url, + headers=headers, + timeout=10.0, + ) + if resp.status_code != 200: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Error fetching user data from provider", + ) + return resp.json() + + def _find_or_create_user_and_link( + self, + provider: IdentityProvider, + provider_user_id: str, + email: Optional[str], + full_name: Optional[str], + avatar_url: Optional[str], + ) -> User: + social = self.social_repository.find_by_provider_and_provider_user_id( + provider.id, provider_user_id + ) + if social: + user = social.user + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is inactive", + ) + return user + if not email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provider did not return email. Email is required to link the account.", + ) + existing = self.user_repository.find_by_email(email) + if existing: + user = existing + else: + user = User( + email=email, + hashed_password=None, + full_name=full_name or email.split("@")[0], + is_active=True, + role="user", + avatar_url=avatar_url, + ) + user = self.user_repository.create(user) + link = UserSocialAccount( + user_id=user.id, + identity_provider_id=provider.id, + provider_user_id=provider_user_id, + provider_email=email, + ) + self.social_repository.create(link) + return user diff --git a/api/app/auth/infra/identity_provider_model.py b/api/app/auth/infra/identity_provider_model.py new file mode 100644 index 0000000..03054f4 --- /dev/null +++ b/api/app/auth/infra/identity_provider_model.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text +from sqlalchemy.sql import func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from uuid import uuid4 + +from app.shared.database.database import Base + + +class IdentityProvider(Base): + """OAuth2/OIDC identity provider configuration (Google, Microsoft, etc.).""" + + __tablename__ = "identity_providers" + + id = Column(Integer, primary_key=True, index=True) + uuid = Column(UUID(as_uuid=True), default=uuid4, unique=True, nullable=False) + slug = Column(String(64), unique=True, nullable=False, index=True) + display_name = Column(String(255), nullable=False) + client_id = Column(String(512), nullable=False) + client_secret_encrypted = Column(Text, nullable=True) + authorization_url = Column(String(1024), nullable=False) + token_url = Column(String(1024), nullable=False) + userinfo_url = Column(String(1024), nullable=True) + scopes = Column(String(512), nullable=False, default="openid email profile") + is_enabled = Column(Boolean, default=True, nullable=False) + + # Optional: for future per-organization providers + organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True) + organization = relationship("Organization", backref="identity_providers") + + created_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column( + DateTime, server_default=func.now(), onupdate=func.now(), nullable=False + ) + + user_social_accounts = relationship( + "UserSocialAccount", back_populates="identity_provider" + ) diff --git a/api/app/auth/infra/identity_provider_repository.py b/api/app/auth/infra/identity_provider_repository.py new file mode 100644 index 0000000..75746a8 --- /dev/null +++ b/api/app/auth/infra/identity_provider_repository.py @@ -0,0 +1,64 @@ +from sqlalchemy.orm import Session +from uuid import UUID +from typing import Optional, List + +from app.auth.infra.identity_provider_model import IdentityProvider + + +class IdentityProviderRepository: + def __init__(self, database_session: Session): + self.db = database_session + + def find_by_id(self, id: int) -> Optional[IdentityProvider]: + return self.db.query(IdentityProvider).filter(IdentityProvider.id == id).first() + + def find_by_uuid(self, uuid: UUID) -> Optional[IdentityProvider]: + return ( + self.db.query(IdentityProvider) + .filter(IdentityProvider.uuid == uuid) + .first() + ) + + def find_by_slug(self, slug: str) -> Optional[IdentityProvider]: + return ( + self.db.query(IdentityProvider) + .filter(IdentityProvider.slug == slug) + .first() + ) + + def find_all( + self, + skip: int = 0, + limit: int = 100, + enabled_only: bool = False, + organization_id: Optional[int] = None, + ) -> List[IdentityProvider]: + query = self.db.query(IdentityProvider) + if enabled_only: + query = query.filter(IdentityProvider.is_enabled.is_(True)) + if organization_id is not None: + query = query.filter(IdentityProvider.organization_id == organization_id) + return ( + query.order_by(IdentityProvider.display_name) + .offset(skip) + .limit(limit) + .all() + ) + + def create(self, provider: IdentityProvider) -> IdentityProvider: + self.db.add(provider) + self.db.commit() + self.db.refresh(provider) + return provider + + def update(self, provider: IdentityProvider) -> IdentityProvider: + self.db.commit() + self.db.refresh(provider) + return provider + + def delete(self, provider: IdentityProvider) -> None: + self.db.delete(provider) + self.db.commit() + + def rollback(self) -> None: + self.db.rollback() diff --git a/api/app/auth/infra/user_social_account_model.py b/api/app/auth/infra/user_social_account_model.py new file mode 100644 index 0000000..9d4bbf1 --- /dev/null +++ b/api/app/auth/infra/user_social_account_model.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship + +from app.shared.database.database import Base + + +class UserSocialAccount(Base): + """Links a User to an identity provider (e.g. Google sub).""" + + __tablename__ = "user_social_accounts" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + identity_provider_id = Column( + Integer, ForeignKey("identity_providers.id", ondelete="CASCADE"), nullable=False + ) + provider_user_id = Column(String(255), nullable=False) + provider_email = Column(String(512), nullable=True) + + created_at = Column(DateTime, server_default=func.now(), nullable=False) + + __table_args__ = ( + UniqueConstraint( + "identity_provider_id", + "provider_user_id", + name="uix_identity_provider_provider_user_id", + ), + ) + + user = relationship("User", backref="social_accounts") + identity_provider = relationship( + "IdentityProvider", back_populates="user_social_accounts" + ) diff --git a/api/app/auth/infra/user_social_account_repository.py b/api/app/auth/infra/user_social_account_repository.py new file mode 100644 index 0000000..086dd6d --- /dev/null +++ b/api/app/auth/infra/user_social_account_repository.py @@ -0,0 +1,38 @@ +from sqlalchemy.orm import Session +from typing import Optional, List + +from app.auth.infra.user_social_account_model import UserSocialAccount + + +class UserSocialAccountRepository: + def __init__(self, database_session: Session): + self.db = database_session + + def find_by_provider_and_provider_user_id( + self, identity_provider_id: int, provider_user_id: str + ) -> Optional[UserSocialAccount]: + return ( + self.db.query(UserSocialAccount) + .filter( + UserSocialAccount.identity_provider_id == identity_provider_id, + UserSocialAccount.provider_user_id == provider_user_id, + ) + .first() + ) + + def find_by_user_id(self, user_id: int) -> List[UserSocialAccount]: + return ( + self.db.query(UserSocialAccount) + .filter(UserSocialAccount.user_id == user_id) + .all() + ) + + def create(self, account: UserSocialAccount) -> UserSocialAccount: + self.db.add(account) + self.db.commit() + self.db.refresh(account) + return account + + def delete(self, account: UserSocialAccount) -> None: + self.db.delete(account) + self.db.commit() diff --git a/api/app/main.py b/api/app/main.py index dd339d6..3666be6 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -23,6 +23,7 @@ ) from app.users.api.user_handlers import router as users_router from app.auth.api.auth_handlers import router as auth_router +from app.auth.api.identity_provider_handlers import router as identity_provider_router from app.settings.api.settings_handlers import router as settings_router from app.dashboard.api.dashboard_handlers import router as dashboard_router from app.webapps.api.webapp_handlers import router as webapps_router @@ -57,6 +58,8 @@ from app.templates.infra.component_template_config_model import ComponentTemplateConfig # noqa: F401, E402 from app.settings.infra.settings_model import Settings # noqa: F401, E402 from app.auth.infra.token_model import Token # noqa: F401, E402 +from app.auth.infra.identity_provider_model import IdentityProvider # noqa: F401, E402 +from app.auth.infra.user_social_account_model import UserSocialAccount # noqa: F401, E402 from app.webapps.infra.application_component_model import ApplicationComponent # noqa: F401, E402 from app.shared.infra.cluster_instance_model import ClusterInstance # noqa: F401, E402 @@ -72,14 +75,16 @@ ) # CORS Configuration +# Include common dev origins so preflight (OPTIONS) succeeds from portal CORS_ORIGINS = os.getenv( - "CORS_ORIGINS", "http://localhost:3000,http://localhost:80" + "CORS_ORIGINS", + "http://localhost:3000,http://localhost:80,http://127.0.0.1:3000,http://127.0.0.1:80", ).split(",") CORS_ORIGINS = [origin.strip() for origin in CORS_ORIGINS if origin.strip()] CORS_ALLOW_CREDENTIALS = os.getenv("CORS_ALLOW_CREDENTIALS", "true").lower() == "true" CORS_ALLOW_METHODS = os.getenv( - "CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS" + "CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS" ).split(",") CORS_ALLOW_METHODS = [method.strip() for method in CORS_ALLOW_METHODS if method.strip()] @@ -107,6 +112,7 @@ app.include_router(component_template_config_router) app.include_router(users_router) app.include_router(auth_router) +app.include_router(identity_provider_router, prefix="/auth") app.include_router(settings_router) app.include_router(dashboard_router) app.include_router(webapps_router) diff --git a/api/tests/integration/test_auth_api.py b/api/tests/integration/test_auth_api.py index f1015e2..2855c31 100644 --- a/api/tests/integration/test_auth_api.py +++ b/api/tests/integration/test_auth_api.py @@ -27,7 +27,7 @@ def test_login_invalid_email(client): ) assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert "incorretos" in response.json()["detail"].lower() + assert "invalid" in response.json()["detail"].lower() def test_login_invalid_password(client, admin_user): @@ -38,7 +38,7 @@ def test_login_invalid_password(client, admin_user): ) assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert "incorretos" in response.json()["detail"].lower() + assert "invalid" in response.json()["detail"].lower() def test_login_missing_fields(client): diff --git a/docker/.env.example b/docker/.env.example index c642381..ddf5e6f 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -35,6 +35,15 @@ CORS_ORIGINS=https://your-domain.com # API URL for Portal (use /api when behind nginx proxy) API_URL=/api +# SSO / OAuth (identity providers) +# Public URL of the API (used as redirect_uri base for OAuth callbacks) +# Example: https://api.your-domain.com or http://localhost:8000 +API_PUBLIC_URL=https://api.your-domain.com + +# URL of the Portal (where to redirect after successful social login) +# Example: https://app.your-domain.com or http://localhost:3000 +FRONTEND_URL=https://app.your-domain.com + # ============================================================================= # Initial Setup # ============================================================================= diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 3e34d37..4bfba0d 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -17,7 +17,7 @@ x-env-api: &shared_env_api DB_NAME: api CORS_ORIGINS: "http://localhost:3000,http://localhost:80,http://portal:3000" CORS_ALLOW_CREDENTIALS: "true" - CORS_ALLOW_METHODS: "GET,POST,PUT,DELETE,OPTIONS" + CORS_ALLOW_METHODS: "GET,POST,PUT,PATCH,DELETE,OPTIONS" CORS_ALLOW_HEADERS: "Content-Type,Authorization,Accept,Origin,X-Requested-With" SECRET_KEY: "your-super-secret-key-minimum-32-characters" TRON_DEFAULT_ORG_NAME: "default" @@ -25,6 +25,9 @@ x-env-api: &shared_env_api TRON_DEFAULT_ORG_PASSWORD: "admin" # Encryption key for secrets (development only - generate new key for production) TRON_SECRETS_KEY: "NfMA_cWx0cuHAGiwl-qWYfl7u1S6bXvADFt5T9WMfUM=" + # SSO/OAuth: public API URL (redirect_uri base) and portal URL (post-login redirect) + API_PUBLIC_URL: "http://localhost:8000" + FRONTEND_URL: "http://localhost:3000" services: database: diff --git a/portal/src/App.tsx b/portal/src/App.tsx index d43faf1..71253b5 100644 --- a/portal/src/App.tsx +++ b/portal/src/App.tsx @@ -5,6 +5,7 @@ import { ProtectedRoute, Layout } from './shared/components' import SetupGuard from './components/SetupGuard' import Home from './pages/home' import Login from './pages/Login' +import LoginCallback from './pages/LoginCallback' import Setup from './pages/setup/Setup' import Clusters from './pages/clusters/Clusters' import Environments from './pages/environments/Environments' @@ -19,6 +20,7 @@ import CronDetail from './pages/applications/CronDetail' import Templates from './pages/templates/Templates' import Profile from './pages/Profile' import Users from './pages/users/Users' +import IdentityProviders from './pages/identity-providers/IdentityProviders' import Tokens from './pages/tokens/Tokens' import Organizations from './pages/organizations/Organizations' import OrganizationDetail from './pages/organizations/OrganizationDetail' @@ -32,6 +34,7 @@ function App() { } /> } /> + } /> }> } /> } /> @@ -50,6 +53,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/portal/src/contexts/AuthContext.tsx b/portal/src/contexts/AuthContext.tsx index 2be613f..d3beb73 100644 --- a/portal/src/contexts/AuthContext.tsx +++ b/portal/src/contexts/AuthContext.tsx @@ -8,6 +8,7 @@ interface AuthContextType { isAuthenticated: boolean isLoading: boolean login: (email: string, password: string) => Promise + loginWithTokens: (accessToken: string, refreshToken: string) => Promise register: (data: UserCreate) => Promise logout: () => void refreshToken: () => Promise @@ -52,6 +53,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(userData) }, []) + const loginWithTokens = useCallback(async (accessToken: string, refreshToken: string) => { + localStorage.setItem('access_token', accessToken) + localStorage.setItem('refresh_token', refreshToken) + const userData = await authApi.getMe() + setUser(userData) + }, []) + const register = useCallback(async (data: UserCreate) => { await authApi.register(data) // After registration, automatically log in @@ -83,6 +91,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { isAuthenticated: !!user, isLoading, login, + loginWithTokens, register, logout, refreshToken, diff --git a/portal/src/features/identity-providers/api.ts b/portal/src/features/identity-providers/api.ts new file mode 100644 index 0000000..be6abe5 --- /dev/null +++ b/portal/src/features/identity-providers/api.ts @@ -0,0 +1,57 @@ +import { api } from '../../shared/api' +import type { + IdentityProviderPublic, + IdentityProvider, + IdentityProviderCreate, + IdentityProviderUpdate, +} from './types' + +/** Public: list enabled providers for login page (no auth required). */ +export const identityProvidersPublicApi = { + listEnabled: async (): Promise => { + const response = await api.get('/auth/identity-providers', { + params: { enabled_only: true }, + }) + return response.data + }, +} + +/** Admin: full CRUD. */ +export const identityProvidersApi = { + list: async (params?: { + skip?: number + limit?: number + enabled_only?: boolean + }): Promise => { + const response = await api.get('/auth/admin/identity-providers', { + params, + }) + return response.data + }, + get: async (uuid: string): Promise => { + const response = await api.get( + `/auth/admin/identity-providers/${uuid}` + ) + return response.data + }, + create: async (data: IdentityProviderCreate): Promise => { + const response = await api.post( + '/auth/admin/identity-providers', + data + ) + return response.data + }, + update: async ( + uuid: string, + data: IdentityProviderUpdate + ): Promise => { + const response = await api.patch( + `/auth/admin/identity-providers/${uuid}`, + data + ) + return response.data + }, + delete: async (uuid: string): Promise => { + await api.delete(`/auth/admin/identity-providers/${uuid}`) + }, +} diff --git a/portal/src/features/identity-providers/hooks/index.ts b/portal/src/features/identity-providers/hooks/index.ts new file mode 100644 index 0000000..96eba1f --- /dev/null +++ b/portal/src/features/identity-providers/hooks/index.ts @@ -0,0 +1,8 @@ +export { + useEnabledIdentityProviders, + useIdentityProviders, + useIdentityProvider, + useCreateIdentityProvider, + useUpdateIdentityProvider, + useDeleteIdentityProvider, +} from './useIdentityProviders' diff --git a/portal/src/features/identity-providers/hooks/useIdentityProviders.ts b/portal/src/features/identity-providers/hooks/useIdentityProviders.ts new file mode 100644 index 0000000..7d95610 --- /dev/null +++ b/portal/src/features/identity-providers/hooks/useIdentityProviders.ts @@ -0,0 +1,69 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { identityProvidersApi, identityProvidersPublicApi } from '../api' +import type { IdentityProviderCreate, IdentityProviderUpdate } from '../types' + +export const useEnabledIdentityProviders = () => { + return useQuery({ + queryKey: ['identity-providers', 'enabled'], + queryFn: () => identityProvidersPublicApi.listEnabled(), + }) +} + +export const useIdentityProviders = (params?: { + skip?: number + limit?: number + enabled_only?: boolean +}) => { + return useQuery({ + queryKey: ['identity-providers', 'admin', params], + queryFn: () => identityProvidersApi.list(params), + }) +} + +export const useIdentityProvider = (uuid: string | undefined) => { + return useQuery({ + queryKey: ['identity-provider', uuid], + queryFn: () => identityProvidersApi.get(uuid!), + enabled: !!uuid, + }) +} + +export const useCreateIdentityProvider = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (data: IdentityProviderCreate) => identityProvidersApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['identity-providers'] }) + }, + }) +} + +export const useUpdateIdentityProvider = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ + uuid, + data, + }: { + uuid: string + data: IdentityProviderUpdate + }) => identityProvidersApi.update(uuid, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['identity-providers'] }) + queryClient.invalidateQueries({ queryKey: ['identity-provider', variables.uuid] }) + }, + }) +} + +export const useDeleteIdentityProvider = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (uuid: string) => identityProvidersApi.delete(uuid), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['identity-providers'] }) + }, + }) +} diff --git a/portal/src/features/identity-providers/index.ts b/portal/src/features/identity-providers/index.ts new file mode 100644 index 0000000..ad762f5 --- /dev/null +++ b/portal/src/features/identity-providers/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './api' +export * from './hooks' diff --git a/portal/src/features/identity-providers/types.ts b/portal/src/features/identity-providers/types.ts new file mode 100644 index 0000000..ac16608 --- /dev/null +++ b/portal/src/features/identity-providers/types.ts @@ -0,0 +1,46 @@ +export interface IdentityProviderPublic { + slug: string + display_name: string +} + +export interface IdentityProvider { + id: number + uuid: string + slug: string + display_name: string + client_id: string + client_secret_masked?: string | null + authorization_url: string + token_url: string + userinfo_url?: string | null + scopes: string + is_enabled: boolean + organization_id?: number | null + created_at: string + updated_at: string +} + +export interface IdentityProviderCreate { + slug: string + display_name: string + client_id: string + client_secret: string + authorization_url: string + token_url: string + userinfo_url?: string | null + scopes?: string + is_enabled?: boolean + organization_id?: number | null +} + +export interface IdentityProviderUpdate { + display_name?: string + client_id?: string + client_secret?: string + authorization_url?: string + token_url?: string + userinfo_url?: string | null + scopes?: string + is_enabled?: boolean + organization_id?: number | null +} diff --git a/portal/src/pages/Login.tsx b/portal/src/pages/Login.tsx index 52c39d0..1554890 100644 --- a/portal/src/pages/Login.tsx +++ b/portal/src/pages/Login.tsx @@ -1,9 +1,40 @@ import { useState, useEffect } from 'react' import { useNavigate, Link } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext' -import { LogIn, Mail, Lock, AlertCircle, Loader2 } from 'lucide-react' +import { LogIn, Mail, Lock, AlertCircle, Loader2, KeyRound } from 'lucide-react' import { loginSchema } from '../features/auth/schemas' import { validateForm } from '../shared/utils/validation' +import { useEnabledIdentityProviders } from '../features/identity-providers' +import { API_BASE_URL } from '../config/api' + +function ProviderIcon({ slug }: { slug: string }) { + const s = slug.toLowerCase() + if (s.includes('google')) { + return ( + + + + + + + + + ) + } + if (s.includes('microsoft')) { + return ( + + + + + + + + + ) + } + return +} export default function Login() { const [email, setEmail] = useState('') @@ -12,6 +43,7 @@ export default function Login() { const [error, setError] = useState(null) const navigate = useNavigate() const { login, isAuthenticated, isLoading } = useAuth() + const { data: socialProviders = [] } = useEnabledIdentityProviders() // Redirect if already authenticated useEffect(() => { @@ -169,6 +201,32 @@ export default function Login() { Sign In + + {socialProviders.length > 0 && ( + <> +
+
+
+
+
+ or continue with +
+
+
+ Sign in with + {socialProviders.map((provider) => ( + + + {provider.display_name} + + ))} +
+ + )}
diff --git a/portal/src/pages/LoginCallback.tsx b/portal/src/pages/LoginCallback.tsx new file mode 100644 index 0000000..3ffa4b6 --- /dev/null +++ b/portal/src/pages/LoginCallback.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { Loader2, AlertCircle } from 'lucide-react' + +export default function LoginCallback() { + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const { loginWithTokens } = useAuth() + const [error, setError] = useState(null) + + useEffect(() => { + const accessToken = searchParams.get('access_token') + const refreshToken = searchParams.get('refresh_token') + + if (!accessToken || !refreshToken) { + setError('Missing tokens. Please try signing in again.') + return + } + + loginWithTokens(accessToken, refreshToken) + .then(() => { + navigate('/', { replace: true }) + }) + .catch(() => { + setError('Failed to complete sign in. Please try again.') + }) + }, [searchParams, loginWithTokens, navigate]) + + if (error) { + return ( +
+
+ +

Sign in failed

+

{error}

+ + Back to Login + +
+
+ ) + } + + return ( +
+
+ + Completing sign in... +
+
+ ) +} diff --git a/portal/src/pages/identity-providers/IdentityProviders.tsx b/portal/src/pages/identity-providers/IdentityProviders.tsx new file mode 100644 index 0000000..0fdf58b --- /dev/null +++ b/portal/src/pages/identity-providers/IdentityProviders.tsx @@ -0,0 +1,537 @@ +import { useState, useEffect } from 'react' +import { X, Trash2, Plus, Edit, ShieldCheck, Key } from 'lucide-react' +import { + useIdentityProviders, + useCreateIdentityProvider, + useUpdateIdentityProvider, + useDeleteIdentityProvider, +} from '../../features/identity-providers' +import type { + IdentityProvider, + IdentityProviderCreate, + IdentityProviderUpdate, +} from '../../features/identity-providers' +import { DataTable, Breadcrumbs, PageHeader } from '../../shared/components' + +const GOOGLE_URLS = { + authorization_url: 'https://accounts.google.com/o/oauth2/v2/auth', + token_url: 'https://oauth2.googleapis.com/token', + userinfo_url: 'https://openidconnect.googleapis.com/v1/userinfo', +} + +type ProviderType = 'google' | 'microsoft' | 'other' + +function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'provider' +} + +const defaultForm: Omit & { client_secret_optional?: string } = { + display_name: '', + client_id: '', + client_secret: '', + authorization_url: '', + token_url: '', + userinfo_url: '', + scopes: 'openid email profile', + is_enabled: true, +} + +function IdentityProviders() { + const [isOpen, setIsOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [notification, setNotification] = useState<{ + type: 'success' | 'error' + message: string + } | null>(null) + const [formData, setFormData] = useState(defaultForm) + const [providerType, setProviderType] = useState('google') + + const { data: providers = [], isLoading } = useIdentityProviders() + const createMutation = useCreateIdentityProvider() + const updateMutation = useUpdateIdentityProvider() + const deleteMutation = useDeleteIdentityProvider() + + useEffect(() => { + if (createMutation.isSuccess) { + setNotification({ type: 'success', message: 'Identity provider created' }) + setIsOpen(false) + setEditing(null) + setProviderType('google') + setFormData(defaultForm) + setTimeout(() => setNotification(null), 5000) + createMutation.reset() + } + }, [createMutation.isSuccess, createMutation]) + + useEffect(() => { + if (createMutation.isError) { + setNotification({ + type: 'error', + message: + (createMutation.error as { response?: { data?: { detail?: string } } })?.response?.data + ?.detail || 'Error creating identity provider', + }) + setTimeout(() => setNotification(null), 5000) + createMutation.reset() + } + }, [createMutation.isError, createMutation]) + + useEffect(() => { + if (updateMutation.isSuccess) { + setNotification({ type: 'success', message: 'Identity provider updated' }) + setIsOpen(false) + setEditing(null) + setProviderType('google') + setFormData(defaultForm) + setTimeout(() => setNotification(null), 5000) + updateMutation.reset() + } + }, [updateMutation.isSuccess, updateMutation]) + + useEffect(() => { + if (updateMutation.isError) { + setNotification({ + type: 'error', + message: + (updateMutation.error as { response?: { data?: { detail?: string } } })?.response?.data + ?.detail || 'Error updating identity provider', + }) + setTimeout(() => setNotification(null), 5000) + updateMutation.reset() + } + }, [updateMutation.isError, updateMutation]) + + useEffect(() => { + if (deleteMutation.isSuccess) { + setNotification({ type: 'success', message: 'Identity provider deleted' }) + setDeleteTarget(null) + setTimeout(() => setNotification(null), 5000) + deleteMutation.reset() + } + }, [deleteMutation.isSuccess, deleteMutation]) + + useEffect(() => { + if (deleteMutation.isError) { + setNotification({ + type: 'error', + message: + (deleteMutation.error as { response?: { data?: { detail?: string } } })?.response?.data + ?.detail || 'Error deleting identity provider', + }) + setTimeout(() => setNotification(null), 5000) + deleteMutation.reset() + } + }, [deleteMutation.isError, deleteMutation]) + + const handleOpenCreate = () => { + setEditing(null) + setProviderType('google') + setFormData({ + ...defaultForm, + display_name: 'Google', + authorization_url: GOOGLE_URLS.authorization_url, + token_url: GOOGLE_URLS.token_url, + userinfo_url: GOOGLE_URLS.userinfo_url, + }) + setIsOpen(true) + } + + const handleProviderTypeChange = (type: ProviderType) => { + setProviderType(type) + if (type === 'google') { + setFormData((prev) => ({ + ...prev, + display_name: 'Google', + authorization_url: GOOGLE_URLS.authorization_url, + token_url: GOOGLE_URLS.token_url, + userinfo_url: GOOGLE_URLS.userinfo_url, + })) + } else if (type === 'microsoft') { + setFormData((prev) => ({ + ...prev, + display_name: 'Microsoft', + authorization_url: '', + token_url: '', + userinfo_url: '', + })) + } else { + setFormData((prev) => ({ + ...prev, + display_name: '', + authorization_url: '', + token_url: '', + userinfo_url: '', + })) + } + } + + const handleEdit = (provider: IdentityProvider) => { + setEditing(provider) + setProviderType( + provider.slug === 'google' ? 'google' : provider.slug === 'microsoft' ? 'microsoft' : 'other' + ) + setFormData({ + display_name: provider.display_name, + client_id: provider.client_id, + client_secret: '', + authorization_url: provider.authorization_url, + token_url: provider.token_url, + userinfo_url: provider.userinfo_url || '', + scopes: provider.scopes, + is_enabled: provider.is_enabled, + }) + setIsOpen(true) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (editing) { + const data: IdentityProviderUpdate = { + display_name: formData.display_name, + client_id: formData.client_id, + authorization_url: formData.authorization_url, + token_url: formData.token_url, + userinfo_url: formData.userinfo_url || null, + scopes: formData.scopes, + is_enabled: formData.is_enabled, + } + if (formData.client_secret) data.client_secret = formData.client_secret + await updateMutation.mutateAsync({ uuid: editing.uuid, data }) + } else { + const slug = + providerType === 'google' + ? 'google' + : providerType === 'microsoft' + ? 'microsoft' + : slugify(formData.display_name) + await createMutation.mutateAsync({ + slug, + display_name: formData.display_name, + client_id: formData.client_id, + client_secret: formData.client_secret, + authorization_url: formData.authorization_url, + token_url: formData.token_url, + userinfo_url: formData.userinfo_url || undefined, + scopes: formData.scopes, + is_enabled: formData.is_enabled, + }) + } + } + + const handleDelete = (provider: IdentityProvider) => { + setDeleteTarget(provider) + } + + const confirmDelete = async () => { + if (!deleteTarget) return + await deleteMutation.mutateAsync(deleteTarget.uuid) + setDeleteTarget(null) + } + + return ( +
+ + +
+ + +
+ + {notification && ( +
+ {notification.message} + +
+ )} + +
+ + searchable={true} + searchPlaceholder="Search by slug or name..." + columns={[ + { + key: 'slug', + label: 'Slug', + render: (p) => ( +
+ + {p.slug} +
+ ), + }, + { + key: 'display_name', + label: 'Display Name', + render: (p) => ( + {p.display_name} + ), + }, + { + key: 'client_id', + label: 'Client ID', + render: (p) => ( + + {p.client_id} + + ), + }, + { + key: 'is_enabled', + label: 'Status', + render: (p) => ( +
+ {p.is_enabled ? ( + + + Enabled + + ) : ( + + + Disabled + + )} +
+ ), + }, + ]} + data={providers} + isLoading={isLoading} + emptyMessage="No identity providers configured" + loadingColor="blue" + getRowKey={(p) => p.uuid} + actions={(p) => [ + { + label: 'Edit', + icon: , + onClick: () => handleEdit(p), + variant: 'default', + }, + { + label: 'Delete', + icon: , + onClick: () => handleDelete(p), + variant: 'danger', + }, + ]} + /> +
+ + {/* Modal Create/Edit */} + {isOpen && ( +
+
+
+

+ {editing ? 'Edit Identity Provider' : 'New Identity Provider'} +

+ +
+ +
+ {!editing && ( +
+ + +
+ )} + +
+ + setFormData({ ...formData, display_name: e.target.value })} + className="input w-full" + placeholder={providerType === 'other' ? 'e.g. My IdP' : undefined} + required + /> + {!editing && providerType === 'other' && ( +

+ Slug will be generated from this name (e.g. "My IdP" → my-idp). +

+ )} +
+ +
+ + setFormData({ ...formData, client_id: e.target.value })} + className="input w-full" + required + /> +
+ +
+ + setFormData({ ...formData, client_secret: e.target.value })} + className="input w-full" + placeholder={editing ? '••••••••' : ''} + required={!editing} + /> +
+ +
+ + setFormData({ ...formData, authorization_url: e.target.value })} + className="input w-full" + required + /> +
+ +
+ + setFormData({ ...formData, token_url: e.target.value })} + className="input w-full" + required + /> +
+ +
+ + setFormData({ ...formData, userinfo_url: e.target.value })} + className="input w-full" + /> +
+ +
+ + setFormData({ ...formData, scopes: e.target.value })} + className="input w-full" + placeholder="openid email profile" + /> +
+ +
+ setFormData({ ...formData, is_enabled: e.target.checked })} + className="rounded border-neutral-300" + /> + +
+ +
+ + +
+
+
+
+ )} + + {/* Delete confirmation */} + {deleteTarget && ( +
+
+

Delete Identity Provider?

+

+ Remove "{deleteTarget.display_name}" ({deleteTarget.slug})? Users who signed in with this + provider will need to use another method or re-link their account. +

+
+ + +
+
+
+ )} +
+ ) +} + +export default IdentityProviders diff --git a/portal/src/pages/users/Users.tsx b/portal/src/pages/users/Users.tsx index 10d43b3..be90ecc 100644 --- a/portal/src/pages/users/Users.tsx +++ b/portal/src/pages/users/Users.tsx @@ -298,15 +298,15 @@ function Users() { render: (user) => (
{user.is_active ? ( - <> - - Active - + + + Active + ) : ( - <> - - Inactive - + + + Inactive + )}
), diff --git a/portal/src/shared/components/Layout.tsx b/portal/src/shared/components/Layout.tsx index 38d839d..6a5b455 100644 --- a/portal/src/shared/components/Layout.tsx +++ b/portal/src/shared/components/Layout.tsx @@ -20,6 +20,7 @@ import { ChevronRight, Building2, ChevronDown, + ShieldCheck, } from 'lucide-react' import { Logo } from './Logo' import { useAuth } from '../../contexts/AuthContext' @@ -41,6 +42,7 @@ const orgNavItems = [ const administrativeNavItems = [ { label: 'Users', path: '/users', icon: Users }, + { label: 'Identity Providers', path: '/identity-providers', icon: ShieldCheck }, ] // Palette for organization color badges (project colors + Tailwind) @@ -280,7 +282,16 @@ function Layout() { className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-neutral-600 hover:bg-neutral-100 transition-colors min-w-0 max-w-[180px] md:max-w-[220px]" title={user.full_name || user.email} > - + {user.avatar_url ? ( + + ) : ( + + )} {user.full_name || user.email}