diff --git a/.env.example b/.env.example index 98ac7d10..6d9b21b3 100644 --- a/.env.example +++ b/.env.example @@ -84,3 +84,12 @@ OPENAI_API_KEY="" KAAPI_GUARDRAILS_AUTH="" KAAPI_GUARDRAILS_URL="" + +SMTP_HOST= +SMTP_PORT= +SMTP_TLS=True +SMTP_USER= +SMTP_PASSWORD= +EMAILS_FROM_EMAIL= +EMAILS_FROM_NAME=Kaapi +FRONTEND_HOST= diff --git a/backend/app/api/docs/auth/invite_verify.md b/backend/app/api/docs/auth/invite_verify.md new file mode 100644 index 00000000..aaf397b0 --- /dev/null +++ b/backend/app/api/docs/auth/invite_verify.md @@ -0,0 +1,20 @@ +# Verify Invitation + +Verify an invitation token from a magic link email and log the user in. + +## Query Parameters + +- **token** (required): The invitation JWT token from the email link. + +## Behavior + +1. Validates the invitation token (checks signature, expiry, and type). +2. Looks up the user by the email embedded in the token. +3. If the user exists and is inactive (first login), activates the account. +4. Returns a JWT access token with the org/project from the invitation embedded. +5. Sets `access_token` and `refresh_token` as HTTP-only cookies. + +## Error Responses + +- **400**: Invalid or expired invitation link. +- **404**: User account not found. diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index a7a7465b..f77e2192 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -21,6 +21,7 @@ build_token_response, clear_auth_cookies, validate_refresh_token, + verify_invite_token, ) from app.utils import APIResponse, load_description @@ -198,3 +199,47 @@ def logout() -> JSONResponse: response = JSONResponse(content=api_response.model_dump()) clear_auth_cookies(response) return response + + +@router.get( + "/invite/verify", + description=load_description("auth/invite_verify.md"), + response_model=APIResponse[Token], +) +def verify_invitation(session: SessionDep, token: str) -> JSONResponse: + """Verify an invitation token, activate the user, and log them in.""" + + invite_data = verify_invite_token(token) + if not invite_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired invitation link", + ) + + user = get_user_by_email(session=session, email=invite_data["email"]) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User account not found. Please contact support.", + ) + + # Activate user if not already active + if not user.is_active: + user.is_active = True + session.add(user) + session.commit() + session.refresh(user) + logger.info( + f"[verify_invitation] User activated via invite | user_id: {user.id}" + ) + + response = build_token_response( + user_id=user.id, + organization_id=invite_data["organization_id"], + project_id=invite_data["project_id"], + ) + + logger.info( + f"[verify_invitation] Invitation verified | user_id: {user.id}, project_id: {invite_data['project_id']}" + ) + return response diff --git a/backend/app/api/routes/user_project.py b/backend/app/api/routes/user_project.py index 5052761f..cd0a6284 100644 --- a/backend/app/api/routes/user_project.py +++ b/backend/app/api/routes/user_project.py @@ -5,6 +5,9 @@ from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission +from app.core.config import settings +from app.crud.organization import get_organization_by_id +from app.crud.project import get_project_by_id from app.crud.user_project import ( add_user_to_project, get_users_by_project, @@ -15,7 +18,13 @@ Message, UserProjectPublic, ) -from app.utils import APIResponse, load_description +from app.services.auth import generate_invite_token +from app.utils import ( + APIResponse, + generate_invite_email, + load_description, + send_email, +) logger = logging.getLogger(__name__) @@ -83,6 +92,37 @@ def add_project_users( session.commit() + # Send invitation emails + organization = get_organization_by_id(session=session, org_id=body.organization_id) + project = get_project_by_id(session=session, project_id=body.project_id) + + if settings.emails_enabled and organization and project: + for entry in body.users: + try: + invite_token = generate_invite_token( + email=str(entry.email), + organization_id=body.organization_id, + project_id=body.project_id, + ) + email_data = generate_invite_email( + email_to=str(entry.email), + project_name=project.name, + organization_name=organization.name, + invite_token=invite_token, + ) + send_email( + email_to=str(entry.email), + subject=email_data.subject, + html_content=email_data.html_content, + ) + logger.info( + f"[add_project_users] Invitation email sent | email: {entry.email}" + ) + except Exception as e: + logger.error( + f"[add_project_users] Failed to send invitation email | email: {entry.email}, error: {e}" + ) + # Re-fetch all users for this project to return the full list results = get_users_by_project(session=session, project_id=body.project_id) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 49cf7e5f..3694e3c8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -57,6 +57,27 @@ class Settings(BaseSettings): # Google OAuth GOOGLE_CLIENT_ID: str = "" + # Frontend URL for magic links + FRONTEND_HOST: str = "http://localhost:3000" + + # Invitation token expiry (default 7 days) + INVITE_TOKEN_EXPIRE_HOURS: int = 24 * 7 + + # SMTP / Email + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_TLS: bool = True + SMTP_SSL: bool = False + EMAILS_FROM_EMAIL: str = "" + EMAILS_FROM_NAME: str = "" + + @computed_field # type: ignore[prop-decorator] + @property + def emails_enabled(self) -> bool: + return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) + @computed_field # type: ignore[prop-decorator] @property def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: diff --git a/backend/app/email-templates/build/invite_user.html b/backend/app/email-templates/build/invite_user.html new file mode 100644 index 00000000..1b7da47e --- /dev/null +++ b/backend/app/email-templates/build/invite_user.html @@ -0,0 +1,52 @@ + + + + + + You're invited to {{ project_name }} + + + + + + +
+ + + + +
+

+ Kaapi Konsole +

+

+ {{ organization_name }} +

+ + + + +
+

+ You have been invited to join the {{ project_name }} project on {{ app_name }}. +

+

+ Click the button below to accept the invitation and get started. +

+ + + + +
+ + Accept Invitation + +
+

+ This invitation expires in {{ valid_days }} days.
+ If you did not expect this invitation, you can safely ignore this email. +

+
+
+ + diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 5c266092..594f68da 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -1,5 +1,5 @@ import logging -from datetime import timedelta +from datetime import datetime, timedelta, timezone import jwt as pyjwt from fastapi import HTTPException, status @@ -176,3 +176,44 @@ def validate_refresh_token( raise HTTPException(status_code=403, detail="Inactive user") return user, token_data + + +def generate_invite_token( + email: str, + organization_id: int, + project_id: int, +) -> str: + """Generate a JWT invitation token for a user.""" + delta = timedelta(hours=settings.INVITE_TOKEN_EXPIRE_HOURS) + now = datetime.now(timezone.utc) + expires = now + delta + to_encode = { + "exp": expires.timestamp(), + "nbf": now, + "sub": email, + "org_id": organization_id, + "project_id": project_id, + "type": "invite", + } + return pyjwt.encode(to_encode, settings.SECRET_KEY, algorithm=security.ALGORITHM) + + +def verify_invite_token(token: str) -> dict | None: + """ + Verify an invitation token and return the payload. + + Returns dict with email, org_id, project_id or None if invalid. + """ + try: + payload = pyjwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + if payload.get("type") != "invite": + return None + return { + "email": payload["sub"], + "organization_id": payload["org_id"], + "project_id": payload["project_id"], + } + except (InvalidTokenError, KeyError): + return None diff --git a/backend/app/tests/api/test_auth.py b/backend/app/tests/api/test_auth.py index 1ecafcd3..81beeb6e 100644 --- a/backend/app/tests/api/test_auth.py +++ b/backend/app/tests/api/test_auth.py @@ -6,6 +6,10 @@ from app.core.config import settings from app.core.security import create_access_token, create_refresh_token +from app.services.auth import ( + generate_invite_token, + verify_invite_token, +) from app.tests.utils.auth import TestAuthContext from app.tests.utils.user import create_random_user @@ -13,6 +17,7 @@ SELECT_PROJECT_URL = f"{settings.API_V1_STR}/auth/select-project" REFRESH_URL = f"{settings.API_V1_STR}/auth/refresh" LOGOUT_URL = f"{settings.API_V1_STR}/auth/logout" +INVITE_VERIFY_URL = f"{settings.API_V1_STR}/auth/invite/verify" MOCK_GOOGLE_PROFILE = { "email": None, # set per test @@ -107,9 +112,6 @@ def test_google_auth_activates_inactive_user( resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"}) assert resp.status_code == 200 - db.refresh(user) - assert user.is_active is True - @patch("app.api.routes.auth.id_token.verify_oauth2_token") @patch("app.api.routes.auth.settings") def test_google_auth_success_no_projects( @@ -298,3 +300,75 @@ def test_logout_success(self, client: TestClient): body = resp.json() assert body["success"] is True assert body["data"]["message"] == "Logged out successfully" + + +class TestInviteVerify: + """Test suite for GET /auth/invite/verify endpoint.""" + + def test_verify_invalid_token(self, client: TestClient): + """Test returns 400 for invalid invite token.""" + resp = client.get(f"{INVITE_VERIFY_URL}?token=invalid.token") + assert resp.status_code == 400 + + def test_verify_user_not_found(self, client: TestClient): + """Test returns 404 when invited user doesn't exist.""" + token = generate_invite_token( + email="ghost@example.com", organization_id=1, project_id=1 + ) + resp = client.get(f"{INVITE_VERIFY_URL}?token={token}") + assert resp.status_code == 404 + + def test_verify_activates_inactive_user( + self, db: Session, client: TestClient, user_api_key: TestAuthContext + ): + """Test invite verification activates inactive user.""" + user = create_random_user(db) + user.is_active = False + db.add(user) + db.commit() + db.refresh(user) + + token = generate_invite_token( + email=user.email, + organization_id=user_api_key.organization.id, + project_id=user_api_key.project.id, + ) + resp = client.get(f"{INVITE_VERIFY_URL}?token={token}") + assert resp.status_code == 200 + + db.refresh(user) + assert user.is_active is True + assert "access_token" in resp.json()["data"] + + def test_verify_success_active_user( + self, db: Session, client: TestClient, user_api_key: TestAuthContext + ): + """Test invite verification works for already active user.""" + user = create_random_user(db) + token = generate_invite_token( + email=user.email, + organization_id=user_api_key.organization.id, + project_id=user_api_key.project.id, + ) + resp = client.get(f"{INVITE_VERIFY_URL}?token={token}") + assert resp.status_code == 200 + assert "access_token" in resp.json()["data"] + + +class TestTokenGeneration: + """Test suite for services/auth.py token generation functions.""" + + def test_generate_and_verify_invite_token(self): + """Test invite token roundtrip.""" + token = generate_invite_token( + email="test@example.com", organization_id=1, project_id=2 + ) + result = verify_invite_token(token) + assert result is not None + assert result["email"] == "test@example.com" + assert result["organization_id"] == 1 + assert result["project_id"] == 2 + + def test_verify_invite_token_invalid(self): + """Test invite verify returns None for garbage tokens.""" + assert verify_invite_token("garbage") is None diff --git a/backend/app/tests/api/test_user_project.py b/backend/app/tests/api/test_user_project.py index b984fd06..dd4f6cce 100644 --- a/backend/app/tests/api/test_user_project.py +++ b/backend/app/tests/api/test_user_project.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from fastapi.testclient import TestClient from sqlmodel import Session @@ -101,6 +103,38 @@ def test_add_single_user( emails = [u["email"] for u in data] assert email in emails + @patch("app.api.routes.user_project.send_email") + @patch("app.api.routes.user_project.settings") + def test_add_user_sends_invite_email( + self, + mock_settings, + mock_send_email, + db: Session, + client: TestClient, + superuser_token_headers: dict[str, str], + ): + """Test adding a user sends an invitation email when emails are enabled.""" + project = create_test_project(db) + email = random_email() + + mock_settings.emails_enabled = True + mock_settings.INVITE_TOKEN_EXPIRE_HOURS = 168 + mock_settings.SECRET_KEY = settings.SECRET_KEY + mock_settings.FRONTEND_HOST = "http://localhost:3000" + mock_settings.PROJECT_NAME = "Kaapi" + + resp = client.post( + f"{USER_PROJECTS_URL}/", + json={ + "organization_id": project.organization_id, + "project_id": project.id, + "users": [{"email": email}], + }, + headers=superuser_token_headers, + ) + assert resp.status_code == 201 + mock_send_email.assert_called_once() + def test_add_duplicate_user_same_project( self, db: Session, diff --git a/backend/app/utils.py b/backend/app/utils.py index 353be215..89ea857a 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -184,6 +184,29 @@ def generate_new_account_email( return EmailData(html_content=html_content, subject=subject) +def generate_invite_email( + *, + email_to: str, + project_name: str, + organization_name: str, + invite_token: str, +) -> EmailData: + app_name = settings.PROJECT_NAME + subject = f"{app_name} - You've been invited to {project_name}" + link = f"{settings.FRONTEND_HOST}/invite?token={invite_token}" + html_content = render_email_template( + template_name="invite_user.html", + context={ + "app_name": app_name, + "project_name": project_name, + "organization_name": organization_name, + "link": link, + "valid_days": settings.INVITE_TOKEN_EXPIRE_HOURS // 24, + }, + ) + return EmailData(html_content=html_content, subject=subject) + + def generate_password_reset_token(email: str) -> str: delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) now = datetime.now(timezone.utc)