feat(auth): implement OAuth2 exchange code flow for Microsoft login, update environment configurations, and enhance frontend SSO handling

This commit is contained in:
2025-10-20 19:25:29 -05:00
parent f460c2b8aa
commit 1961296593
11 changed files with 793 additions and 7 deletions

View File

@@ -109,3 +109,19 @@ class RegisterResponseDTO(BaseModel):
"message": "User registered successfully"
}
}
class ExchangeCodeRequestDTO(BaseModel):
"""DTO para intercambiar authorization code por tokens (OAuth2 flow)"""
code: str = Field(..., description="Authorization code de OAuth2")
redirect_uri: str = Field(..., description="Redirect URI usado en la autorización")
tenant_slug: Optional[str] = Field(None, description="Slug del tenant (opcional)")
class Config:
json_schema_extra = {
"example": {
"code": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Ii...",
"redirect_uri": "http://localhost:5173/auth/callback",
"tenant_slug": "empresa-abc"
}
}

View File

@@ -14,7 +14,8 @@ from .dto import (
UserInfoResponseDTO,
LogoutRequestDTO,
RegisterRequestDTO,
RegisterResponseDTO
RegisterResponseDTO,
ExchangeCodeRequestDTO
)
from .service import AuthService
@@ -101,6 +102,24 @@ async def logout(
return service.logout(logout_data)
@router.post("/exchange-code", response_model=TokenResponseDTO)
async def exchange_code(
exchange_data: ExchangeCodeRequestDTO,
db: Session = Depends(get_core_db)
):
"""
Intercambia un authorization code de OAuth2 por tokens
Este endpoint es útil cuando el frontend usa el flujo de autorización
con proveedores externos (Microsoft, Google, etc.) a través de Keycloak.
El código se obtiene después de que el usuario se autentica con el proveedor
externo y Keycloak lo redirige al frontend con el código en los query params.
"""
service = AuthService(db)
return service.exchange_code(exchange_data)
@router.get("/health")
async def auth_health():
"""

View File

@@ -275,3 +275,78 @@ class AuthService:
except Exception as e:
logger.error(f"Registration error: {str(e)}")
raise HTTPException(status_code=500, detail="Registration error")
def exchange_code(self, exchange_data) -> TokenResponseDTO:
"""
Intercambia un authorization code por tokens
Este método se usa cuando el frontend recibe un código de autorización
después de un login con proveedor externo (Microsoft, Google, etc.)
a través de Keycloak.
Args:
exchange_data: Datos del código y redirect_uri
Returns:
TokenResponseDTO con access_token y refresh_token
Raises:
HTTPException: Si el código es inválido o expiró
"""
try:
# Importar el DTO aquí para evitar referencias circulares
from .dto import ExchangeCodeRequestDTO
# Intercambiar código por tokens usando Keycloak
token_response = self.keycloak_openid.token(
grant_type='authorization_code',
code=exchange_data.code,
redirect_uri=exchange_data.redirect_uri
)
logger.info(f"Code exchanged successfully")
# Si se proporciona tenant_slug, podríamos validar que el usuario pertenece a ese tenant
# Por ahora simplemente retornamos los tokens
if exchange_data.tenant_slug:
# Decodificar token para obtener tenant_id del usuario
user_info = self.keycloak_openid.introspect(token_response['access_token'])
user_tenant_id = user_info.get('tenant_id')
# Validar que el tenant existe y está activo
from api.v1.modules.a76.tenants.service import TenantService
tenant_service = TenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(exchange_data.tenant_slug)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
if not tenant.is_active:
raise HTTPException(status_code=403, detail="Tenant is not active")
# Opcional: Verificar que el usuario pertenece al tenant
# Esto depende de cómo manejes los tenants en tu aplicación
return TokenResponseDTO(
access_token=token_response['access_token'],
refresh_token=token_response['refresh_token'],
token_type=token_response.get('token_type', 'bearer'),
expires_in=token_response.get('expires_in', 3600)
)
except KeycloakError as e:
error_message = str(e)
logger.warning(f"Code exchange failed: {error_message}")
if "invalid_grant" in error_message.lower():
raise HTTPException(status_code=400, detail="Invalid or expired authorization code")
elif "invalid_client" in error_message.lower():
raise HTTPException(status_code=401, detail="Invalid client credentials")
else:
raise HTTPException(status_code=500, detail="Token exchange error")
except HTTPException:
raise
except Exception as e:
logger.error(f"Code exchange error: {str(e)}")
raise HTTPException(status_code=500, detail="Code exchange error")