diff --git a/backend/app/api/docs/auth/magic_link.md b/backend/app/api/docs/auth/magic_link.md new file mode 100644 index 000000000..714bb9830 --- /dev/null +++ b/backend/app/api/docs/auth/magic_link.md @@ -0,0 +1,18 @@ +# Request Magic Link Login + +Send a magic link login email to the user's email address. + +## Request Body + +- **email** (required): The user's email address. + +## Behavior + +1. Checks if the user exists — returns 404 if not. +2. Generates a short-lived login token (15 minutes). +3. Sends an email with a "Sign In Now" button linking to the frontend. + +## Error Responses + +- **404**: No account found for this email. +- **500**: Email service is not configured or failed to send. diff --git a/backend/app/api/docs/auth/magic_link_verify.md b/backend/app/api/docs/auth/magic_link_verify.md new file mode 100644 index 000000000..e4654f0d4 --- /dev/null +++ b/backend/app/api/docs/auth/magic_link_verify.md @@ -0,0 +1,20 @@ +# Verify Magic Link + +Verify a magic link login token and log the user in. + +## Query Parameters + +- **token** (required): The login JWT token from the email link. + +## Behavior + +1. Validates the magic link token (checks signature, expiry, and type). +2. Looks up the user by the email embedded in the token. +3. Verifies the user is active. +4. If the user has exactly one project, it is auto-selected and embedded in the JWT. +5. Returns a JWT access token and sets HTTP-only cookies. + +## Error Responses + +- **400**: Invalid or expired login link. +- **404**: User account not found. diff --git a/backend/app/api/docs/credentials/delete_all_by_org_project.md b/backend/app/api/docs/credentials/delete_all_by_org_project.md new file mode 100644 index 000000000..15f8e9cc7 --- /dev/null +++ b/backend/app/api/docs/credentials/delete_all_by_org_project.md @@ -0,0 +1,7 @@ +Delete all credentials for a specific organization and project. + +Permanently removes all provider credentials associated with the specified organization and project IDs. Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID diff --git a/backend/app/api/docs/credentials/delete_provider_by_org_project.md b/backend/app/api/docs/credentials/delete_provider_by_org_project.md new file mode 100644 index 000000000..ba69b89bd --- /dev/null +++ b/backend/app/api/docs/credentials/delete_provider_by_org_project.md @@ -0,0 +1,8 @@ +Delete credentials for a specific provider within an organization and project. + +Permanently removes credentials for a specific provider from the specified organization and project. Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID +- **provider**: Provider name (e.g., `openai`, `langfuse`, `google`, `sarvamai`, `elevenlabs`) diff --git a/backend/app/api/docs/credentials/get_provider.md b/backend/app/api/docs/credentials/get_provider.md index 2f3a76920..c7ec981ce 100644 --- a/backend/app/api/docs/credentials/get_provider.md +++ b/backend/app/api/docs/credentials/get_provider.md @@ -1,3 +1,3 @@ Get credentials for a specific provider. -Retrieves decrypted credentials for a specific provider (e.g., `openai`, `langfuse`) for the current organization and project. +Retrieves credentials for a specific provider (e.g., `openai`, `langfuse`) for the current organization and project. Sensitive fields (e.g., `api_key`, `secret_key`) are masked in the response. If credentials for the provider are not configured, `null` is returned. diff --git a/backend/app/api/docs/credentials/get_provider_by_org_project.md b/backend/app/api/docs/credentials/get_provider_by_org_project.md new file mode 100644 index 000000000..accb96fab --- /dev/null +++ b/backend/app/api/docs/credentials/get_provider_by_org_project.md @@ -0,0 +1,8 @@ +Get credentials for a specific provider within an organization and project. + +Retrieves credentials for a specific provider (e.g., `openai`, `langfuse`) for the specified organization and project. Sensitive fields (e.g., `api_key`, `secret_key`) are masked in the response. If credentials for the provider are not configured, `null` is returned. Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID +- **provider**: Provider name (e.g., `openai`, `langfuse`, `google`, `sarvamai`, `elevenlabs`) diff --git a/backend/app/api/docs/credentials/list.md b/backend/app/api/docs/credentials/list.md index c660229bc..ff0612661 100644 --- a/backend/app/api/docs/credentials/list.md +++ b/backend/app/api/docs/credentials/list.md @@ -1,3 +1,3 @@ Get all credentials for current organization and project. -Returns list of all provider credentials associated with your organization and project. +Returns a list of all provider credentials associated with your organization and project. Sensitive fields (e.g., `api_key`, `secret_key`) are masked in the response. If no credentials are configured, an empty list is returned. diff --git a/backend/app/api/docs/credentials/list_by_org_project.md b/backend/app/api/docs/credentials/list_by_org_project.md new file mode 100644 index 000000000..12dad77e6 --- /dev/null +++ b/backend/app/api/docs/credentials/list_by_org_project.md @@ -0,0 +1,12 @@ +Get all credentials for a specific organization and project. + +Retrieves all provider credentials associated with the specified organization and project IDs. Sensitive fields (e.g., `api_key`, `secret_key`) are masked in the response. If no credentials are configured, an empty list is returned. Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID + +### Supported Providers: +- **LLM:** openai, sarvamai, google(gemini) +- **Observability:** langfuse +- **Audio:** elevenlabs diff --git a/backend/app/api/docs/credentials/update.md b/backend/app/api/docs/credentials/update.md index 0377f0e4b..cf08360d4 100644 --- a/backend/app/api/docs/credentials/update.md +++ b/backend/app/api/docs/credentials/update.md @@ -1,3 +1,34 @@ Update credentials for a specific provider. -Updates existing provider credentials for the current organization and project. Provider and credential fields must be provided. +Updates existing provider credentials for the current organization and project. If the credentials for the specified provider don't exist yet, they will be **created** automatically (upsert behavior). The `provider` and `credential` fields are required. + +The `credential` field accepts **two formats** (both work the same): + +### Nested format (same as create endpoint): +```json +{ + "provider": "openai", + "is_active": true, + "credential": { + "openai": { + "api_key": "sk-proj-..." + } + } +} +``` + +### Flat format: +```json +{ + "provider": "openai", + "is_active": true, + "credential": { + "api_key": "sk-proj-..." + } +} +``` + +### Supported Providers: +- **LLM:** openai, sarvamai, google(gemini) +- **Observability:** langfuse +- **Audio:** elevenlabs diff --git a/backend/app/api/docs/credentials/update_by_org_project.md b/backend/app/api/docs/credentials/update_by_org_project.md new file mode 100644 index 000000000..c010871d4 --- /dev/null +++ b/backend/app/api/docs/credentials/update_by_org_project.md @@ -0,0 +1,38 @@ +Update credentials for a specific provider within an organization and project. + +Updates existing provider credentials for the specified organization and project. If the credentials for the specified provider don't exist yet, they will be **created** automatically (upsert behavior). Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID + +The `credential` field accepts **two formats** (both work the same): + +### Nested format (same as create endpoint): +```json +{ + "provider": "openai", + "is_active": true, + "credential": { + "openai": { + "api_key": "sk-proj-..." + } + } +} +``` + +### Flat format: +```json +{ + "provider": "openai", + "is_active": true, + "credential": { + "api_key": "sk-proj-..." + } +} +``` + +### Supported Providers: +- **LLM:** openai, sarvamai, google(gemini) +- **Observability:** langfuse +- **Audio:** elevenlabs diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 24c0c5c13..4c118d73c 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,4 +1,5 @@ import logging +from typing import Any from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import JSONResponse @@ -14,6 +15,7 @@ from app.models import ( GoogleAuthRequest, GoogleAuthResponse, + MagicLinkRequest, Message, SelectProjectRequest, Token, @@ -22,10 +24,17 @@ build_google_auth_response, build_token_response, clear_auth_cookies, + generate_magic_link_token, validate_refresh_token, verify_invite_token, + verify_magic_link_token, +) +from app.utils import ( + APIResponse, + generate_magic_link_email, + load_description, + send_email, ) -from app.utils import APIResponse, load_description logger = logging.getLogger(__name__) @@ -250,3 +259,110 @@ def verify_invitation(session: SessionDep, token: str) -> JSONResponse: f"[verify_invitation] Invitation verified | user_id: {user.id}, project_id: {invite_payload.project_id}" ) return response + + +@router.post( + "/magic-link", + description=load_description("auth/magic_link.md"), + response_model=APIResponse[Message], +) +def request_magic_link(session: SessionDep, body: MagicLinkRequest) -> Any: + """Send a magic link login email to the user.""" + + user = get_user_by_email(session=session, email=body.email) + if not user: + logger.info( + f"[request_magic_link] Magic link requested for non-existent email: {body.email}" + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No account found for this email.", + ) + + token = generate_magic_link_token(email=body.email) + + if settings.emails_enabled: + try: + email_data = generate_magic_link_email( + email_to=body.email, + magic_link_token=token, + ) + send_email( + email_to=body.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + logger.info( + f"[request_magic_link] Magic link email sent | email: {body.email}" + ) + except Exception as e: + logger.error( + f"[request_magic_link] Failed to send magic link email | email: {body.email}, error: {e}" + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to send login email. Please try again later.", + ) + else: + logger.warning("[request_magic_link] Email sending is not configured") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Email service is not configured", + ) + + return APIResponse.success_response( + data=Message(message="If an account exists, a login link has been sent.") + ) + + +@router.get( + "/magic-link/verify", + description=load_description("auth/magic_link_verify.md"), + response_model=APIResponse[Token], +) +def verify_magic_link(session: SessionDep, token: str) -> JSONResponse: + """Verify a magic link token and log the user in.""" + + email = verify_magic_link_token(token) + if not email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired login link. Please request a new one.", + ) + + user = get_user_by_email(session=session, email=email) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User account not found", + ) + + # 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_magic_link] User activated via magic link | user_id: {user.id}" + ) + + # Get user's projects to embed in token + available_projects = get_user_accessible_projects(session=session, user_id=user.id) + + organization_id = None + project_id = None + if len(available_projects) == 1: + organization_id = available_projects[0]["organization_id"] + project_id = available_projects[0]["project_id"] + + response = build_token_response( + user_id=user.id, + organization_id=organization_id, + project_id=project_id, + ) + + logger.info( + f"[verify_magic_link] User logged in via magic link | user_id: {user.id}" + ) + return response diff --git a/backend/app/api/routes/credentials.py b/backend/app/api/routes/credentials.py index 8e1e94b41..be75b5b98 100644 --- a/backend/app/api/routes/credentials.py +++ b/backend/app/api/routes/credentials.py @@ -5,7 +5,7 @@ from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission from app.core.exception_handlers import HTTPException -from app.core.providers import validate_provider +from app.core.providers import mask_credential_fields, validate_provider from app.crud.credentials import ( get_creds_by_org, get_provider_credential, @@ -67,15 +67,13 @@ def read_credential( org_id=_current_user.organization_.id, project_id=_current_user.project_.id, ) - if not creds: - raise HTTPException(status_code=404, detail="Credentials not found") return APIResponse.success_response([cred.to_public() for cred in creds]) @router.get( "/provider/{provider}", - response_model=APIResponse[dict], + response_model=APIResponse[dict | None], description=load_description("credentials/get_provider.md"), dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], ) @@ -97,9 +95,11 @@ def read_provider_credential( project_id=_current_user.project_.id, ) if credential is None: - raise HTTPException(status_code=404, detail="Provider credentials not found") + return APIResponse.success_response(None) - return APIResponse.success_response(credential) + return APIResponse.success_response( + mask_credential_fields(provider_enum, credential) + ) @router.patch( @@ -184,3 +184,147 @@ def delete_all_credentials( return APIResponse.success_response( {"message": "All credentials deleted successfully"} ) + + +@router.get( + "/{org_id}/{project_id}", + response_model=APIResponse[list[CredsPublic]], + description=load_description("credentials/list_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def read_credentials_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + _current_user: AuthContextDep, +): + creds = get_creds_by_org( + session=session, + org_id=org_id, + project_id=project_id, + ) + + return APIResponse.success_response([cred.to_public() for cred in creds]) + + +@router.get( + "/{org_id}/{project_id}/provider/{provider}", + response_model=APIResponse[dict | None], + description=load_description("credentials/get_provider_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def read_provider_credential_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + provider: str, + _current_user: AuthContextDep, +): + try: + provider_enum = validate_provider(provider) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + credential = get_provider_credential( + session=session, + org_id=org_id, + provider=provider_enum, + project_id=project_id, + ) + if credential is None: + return APIResponse.success_response(None) + + return APIResponse.success_response( + mask_credential_fields(provider_enum, credential) + ) + + +@router.patch( + "/{org_id}/{project_id}", + response_model=APIResponse[list[CredsPublic]], + description=load_description("credentials/update_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def update_credential_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + creds_in: CredsUpdate, + _current_user: AuthContextDep, +): + if not creds_in or not creds_in.provider or not creds_in.credential: + logger.error( + f"[update_credential_by_org_project] Invalid input | organization_id: {org_id}, project_id: {project_id}" + ) + raise HTTPException( + status_code=400, detail="Provider and credential must be provided" + ) + + updated_credential = update_creds_for_org( + session=session, + org_id=org_id, + creds_in=creds_in, + project_id=project_id, + ) + + return APIResponse.success_response( + [cred.to_public() for cred in updated_credential] + ) + + +@router.delete( + "/{org_id}/{project_id}/provider/{provider}", + response_model=APIResponse[dict], + description=load_description("credentials/delete_provider_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def delete_provider_credential_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + provider: str, + _current_user: AuthContextDep, +): + try: + provider_enum = validate_provider(provider) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + remove_provider_credential( + session=session, + org_id=org_id, + provider=provider_enum, + project_id=project_id, + ) + + return APIResponse.success_response( + {"message": "Provider credentials removed successfully"} + ) + + +@router.delete( + "/{org_id}/{project_id}", + response_model=APIResponse[dict], + description=load_description("credentials/delete_all_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def delete_all_credentials_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + _current_user: AuthContextDep, +): + remove_creds_for_org( + session=session, + org_id=org_id, + project_id=project_id, + ) + + return APIResponse.success_response( + {"message": "All credentials deleted successfully"} + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 26c744435..ec0692997 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -63,6 +63,9 @@ class Settings(BaseSettings): # Invitation token expiry (default 24 hours) INVITE_TOKEN_EXPIRE_HOURS: int = 24 + # Magic link login token expiry (default 15 minutes) + MAGIC_LINK_TOKEN_EXPIRE_MINUTES: int = 15 + # SMTP / Email SMTP_HOST: str = "" SMTP_PORT: int = 587 diff --git a/backend/app/core/providers.py b/backend/app/core/providers.py index 7248ea1df..793995422 100644 --- a/backend/app/core/providers.py +++ b/backend/app/core/providers.py @@ -1,7 +1,7 @@ import logging -from typing import Dict, List +from typing import Any, Dict, List from enum import Enum -from dataclasses import dataclass +from dataclasses import dataclass, field logger = logging.getLogger(__name__) @@ -21,17 +21,27 @@ class ProviderConfig: """Configuration for a provider including its required credential fields.""" required_fields: List[str] + sensitive_fields: List[str] = field(default_factory=list) # Provider configurations PROVIDER_CONFIGS: Dict[Provider, ProviderConfig] = { - Provider.OPENAI: ProviderConfig(required_fields=["api_key"]), + Provider.OPENAI: ProviderConfig( + required_fields=["api_key"], sensitive_fields=["api_key"] + ), Provider.LANGFUSE: ProviderConfig( - required_fields=["secret_key", "public_key", "host"] + required_fields=["secret_key", "public_key", "host"], + sensitive_fields=["secret_key"], + ), + Provider.GOOGLE: ProviderConfig( + required_fields=["api_key"], sensitive_fields=["api_key"] + ), + Provider.SARVAMAI: ProviderConfig( + required_fields=["api_key"], sensitive_fields=["api_key"] + ), + Provider.ELEVENLABS: ProviderConfig( + required_fields=["api_key"], sensitive_fields=["api_key"] ), - Provider.GOOGLE: ProviderConfig(required_fields=["api_key"]), - Provider.SARVAMAI: ProviderConfig(required_fields=["api_key"]), - Provider.ELEVENLABS: ProviderConfig(required_fields=["api_key"]), } @@ -86,3 +96,29 @@ def validate_provider_credentials(provider: str, credentials: Dict[str, str]) -> def get_supported_providers() -> List[str]: """Return a list of all supported provider names.""" return [p.value for p in Provider] + + +def mask_credential_fields( + provider: str, credentials: Dict[str, Any] +) -> Dict[str, Any]: + """Mask sensitive fields in a credential dict for the given provider. + + Non-sensitive fields (e.g., langfuse `public_key`, `host`) are returned as-is. + Unknown providers are returned with no masking. + """ + from app.utils import mask_string + + if not credentials: + return credentials + + try: + provider_enum = Provider(provider.lower()) + except ValueError: + return credentials + + sensitive_fields = PROVIDER_CONFIGS[provider_enum].sensitive_fields + masked = dict(credentials) + for field_name in sensitive_fields: + if field_name in masked and isinstance(masked[field_name], str): + masked[field_name] = mask_string(masked[field_name]) + return masked diff --git a/backend/app/crud/credentials.py b/backend/app/crud/credentials.py index e6c1ded6e..6853c455a 100644 --- a/backend/app/crud/credentials.py +++ b/backend/app/crud/credentials.py @@ -184,8 +184,18 @@ def update_creds_for_org( if not creds_in.provider or not creds_in.credential: raise ValueError("Provider and credential must be provided") + # Auto-unwrap nested format: {"google": {"api_key": "..."}} -> {"api_key": "..."} + # so the same payload shape works for both create and update. + credential_data = creds_in.credential + if ( + isinstance(credential_data, dict) + and creds_in.provider in credential_data + and isinstance(credential_data[creds_in.provider], dict) + ): + credential_data = credential_data[creds_in.provider] + try: - validate_provider_credentials(creds_in.provider, creds_in.credential) + validate_provider_credentials(creds_in.provider, credential_data) except ValueError as e: logger.error( f"[update_creds_for_org] Validation error | organization_id: {org_id}, project_id: {project_id}, provider: {creds_in.provider}, error: {str(e)}" @@ -193,7 +203,7 @@ def update_creds_for_org( raise HTTPException(status_code=400, detail=str(e)) # Encrypt the entire credentials object - encrypted_credentials = encrypt_credentials(creds_in.credential) + encrypted_credentials = encrypt_credentials(credential_data) statement = select(Credential).where( Credential.organization_id == org_id, @@ -203,12 +213,23 @@ def update_creds_for_org( ) creds = session.exec(statement).one_or_none() if creds is None: - logger.error( - f"[update_creds_for_org] Credentials not found | organization {org_id}, provider {creds_in.provider}, project_id {project_id}" + # Create new credential if it doesn't exist + creds = Credential( + organization_id=org_id, + project_id=project_id, + is_active=creds_in.is_active if creds_in.is_active is not None else True, + provider=creds_in.provider, + credential=encrypted_credentials, + inserted_at=now(), + updated_at=now(), ) - raise HTTPException( - status_code=404, detail="Credentials not found for this provider" + session.add(creds) + session.commit() + session.refresh(creds) + logger.info( + f"[update_creds_for_org] Created new credentials | organization_id {org_id}, provider {creds_in.provider}, project_id {project_id}" ) + return [creds] creds.credential = encrypted_credentials creds.updated_at = now() diff --git a/backend/app/email-templates/build/invite_user.html b/backend/app/email-templates/build/invite_user.html index 1b7da47eb..6371a63d1 100644 --- a/backend/app/email-templates/build/invite_user.html +++ b/backend/app/email-templates/build/invite_user.html @@ -13,7 +13,7 @@

- Kaapi Konsole + {{ app_name }}

{{ organization_name }} diff --git a/backend/app/email-templates/build/magic_link_login.html b/backend/app/email-templates/build/magic_link_login.html new file mode 100644 index 000000000..daf376d9c --- /dev/null +++ b/backend/app/email-templates/build/magic_link_login.html @@ -0,0 +1,52 @@ + + + + + + Sign in to {{ app_name }} + + + + + + +
+ + + + +
+

+ {{ app_name }} +

+

+ Sign in to your account +

+ + + + +
+

+ We received a sign-in request for {{ email }}. +

+

+ Click the button below to sign in. +

+ + + + +
+ + Sign In Now + +
+

+ This link expires in {{ valid_minutes }} minutes.
+ If you did not request this, you can safely ignore this email. +

+
+
+ + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 585cdef89..05f39032e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,6 +13,7 @@ GoogleAuthRequest, GoogleAuthResponse, InviteTokenPayload, + MagicLinkRequest, SelectProjectRequest, Token, TokenPayload, diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index c66876630..68bf9d89a 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -44,6 +44,10 @@ class SelectProjectRequest(SQLModel): project_id: int +class MagicLinkRequest(SQLModel): + email: str + + class AuthContext(SQLModel): user: User organization: Organization | None = None diff --git a/backend/app/models/credentials.py b/backend/app/models/credentials.py index b295927c2..e6741b2e9 100644 --- a/backend/app/models/credentials.py +++ b/backend/app/models/credentials.py @@ -4,6 +4,7 @@ import sqlalchemy as sa from sqlmodel import Field, Relationship, SQLModel +from app.core.providers import mask_credential_fields from app.core.util import now from app.models.organization import Organization from app.models.project import Project @@ -113,19 +114,26 @@ class Credential(CredsBase, table=True): organization: Organization | None = Relationship(back_populates="creds") project: Project | None = Relationship(back_populates="creds") - def to_public(self) -> "CredsPublic": - """Convert the database model to a public model with decrypted credentials.""" + def to_public(self, mask: bool = True) -> "CredsPublic": + """Convert the database model to a public model with decrypted credentials. + + By default, sensitive fields (e.g., api_key, secret_key) are masked so + the response is safe to return via the API. + """ + # Local import to avoid circular dependency (security imports app.models) from app.core.security import decrypt_credentials + decrypted = decrypt_credentials(self.credential) if self.credential else None + if mask and decrypted: + decrypted = mask_credential_fields(self.provider, decrypted) + return CredsPublic( id=self.id, organization_id=self.organization_id, project_id=self.project_id, is_active=self.is_active, provider=self.provider, - credential=decrypt_credentials(self.credential) - if self.credential - else None, + credential=decrypted, inserted_at=self.inserted_at, updated_at=self.updated_at, ) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index ebd4cd1d3..fddd8dde5 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -179,30 +179,79 @@ def validate_refresh_token( return user, token_data -def generate_invite_token( +def generate_email_token( email: str, - organization_id: int, - project_id: int, + token_type: str, + expires_delta: timedelta, + organization_id: int | None = None, + project_id: int | None = None, ) -> str: - """Generate a JWT invitation token for a user.""" + """Generate a JWT email token (invite or magic_link). + + Args: + email: User's email address (stored as 'sub' claim) + token_type: Token type identifier (e.g. "invite", "magic_link") + expires_delta: Token expiration duration + organization_id: Optional org ID to embed (for invite tokens) + project_id: Optional project ID to embed (for invite tokens) + """ return security.encode_jwt_token( subject=email, + token_type=token_type, + expires_delta=expires_delta, + extra_claims={"org_id": organization_id, "project_id": project_id}, + ) + + +def verify_email_token(token: str, expected_type: str) -> dict | None: + """Verify a JWT email token and return its claims as a dict, or None if invalid.""" + payload = security.decode_jwt_token(token, expected_type=expected_type) + if not payload or "sub" not in payload: + return None + return { + "email": payload["sub"], + "organization_id": payload.get("org_id"), + "project_id": payload.get("project_id"), + } + + +def generate_invite_token(email: str, organization_id: int, project_id: int) -> str: + """Generate a JWT invitation token for a user (expires in INVITE_TOKEN_EXPIRE_HOURS).""" + return generate_email_token( + email=email, token_type="invite", expires_delta=timedelta(hours=settings.INVITE_TOKEN_EXPIRE_HOURS), - extra_claims={"org_id": organization_id, "project_id": project_id}, + organization_id=organization_id, + project_id=project_id, ) def verify_invite_token(token: str) -> InviteTokenPayload | None: """Verify an invitation token and return its payload, or None if invalid.""" - payload = security.decode_jwt_token(token, expected_type="invite") - if not payload: - return None - try: - return InviteTokenPayload( - email=payload["sub"], - organization_id=payload["org_id"], - project_id=payload["project_id"], - ) - except KeyError: + claims = verify_email_token(token, expected_type="invite") + if ( + not claims + or claims.get("organization_id") is None + or claims.get("project_id") is None + ): return None + return InviteTokenPayload( + email=claims["email"], + organization_id=claims["organization_id"], + project_id=claims["project_id"], + ) + + +def generate_magic_link_token(email: str) -> str: + """Generate a short-lived magic link login token (expires in MAGIC_LINK_TOKEN_EXPIRE_MINUTES).""" + return generate_email_token( + email=email, + token_type="magic_link", + expires_delta=timedelta(minutes=settings.MAGIC_LINK_TOKEN_EXPIRE_MINUTES), + ) + + +def verify_magic_link_token(token: str) -> str | None: + """Verify a magic link token. Returns email string or None.""" + result = verify_email_token(token, expected_type="magic_link") + return result["email"] if result else None diff --git a/backend/app/tests/api/test_auth.py b/backend/app/tests/api/test_auth.py index d1dbe9810..cfa05dffb 100644 --- a/backend/app/tests/api/test_auth.py +++ b/backend/app/tests/api/test_auth.py @@ -8,7 +8,9 @@ from app.core.security import create_access_token, create_refresh_token from app.services.auth import ( generate_invite_token, + generate_magic_link_token, verify_invite_token, + verify_magic_link_token, ) from app.tests.utils.auth import TestAuthContext from app.tests.utils.user import create_random_user @@ -17,6 +19,8 @@ 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" +MAGIC_LINK_URL = f"{settings.API_V1_STR}/auth/magic-link" +MAGIC_LINK_VERIFY_URL = f"{settings.API_V1_STR}/auth/magic-link/verify" INVITE_VERIFY_URL = f"{settings.API_V1_STR}/auth/invite/verify" MOCK_GOOGLE_PROFILE = { @@ -302,6 +306,114 @@ def test_logout_success(self, client: TestClient): assert body["data"]["message"] == "Logged out successfully" +class TestMagicLink: + """Test suite for POST /auth/magic-link endpoint.""" + + @patch("app.api.routes.auth.settings") + def test_magic_link_email_not_configured(self, mock_settings, client: TestClient): + """Test returns 500 when email is not configured.""" + mock_settings.emails_enabled = False + resp = client.post(MAGIC_LINK_URL, json={"email": "test@example.com"}) + assert resp.status_code == 500 + + def test_magic_link_nonexistent_user(self, client: TestClient): + """Test returns 404 for non-existent user.""" + resp = client.post(MAGIC_LINK_URL, json={"email": "nonexistent@example.com"}) + assert resp.status_code == 404 + assert "No account found" in resp.json()["error"] + + @patch("app.api.routes.auth.send_email") + @patch("app.api.routes.auth.settings") + def test_magic_link_inactive_user_allowed( + self, mock_settings, mock_send, db: Session, client: TestClient + ): + """Test inactive user can still request magic link to reactivate.""" + user = create_random_user(db) + user.is_active = False + db.add(user) + db.commit() + + mock_settings.emails_enabled = True + mock_settings.MAGIC_LINK_TOKEN_EXPIRE_MINUTES = 15 + mock_settings.SECRET_KEY = settings.SECRET_KEY + mock_settings.FRONTEND_HOST = "http://localhost:3000" + mock_settings.PROJECT_NAME = "Kaapi" + + resp = client.post(MAGIC_LINK_URL, json={"email": user.email}) + assert resp.status_code == 200 + mock_send.assert_called_once() + + @patch("app.api.routes.auth.send_email") + @patch("app.api.routes.auth.settings") + def test_magic_link_success( + self, mock_settings, mock_send, db: Session, client: TestClient + ): + """Test sends email for valid active user.""" + user = create_random_user(db) + + mock_settings.emails_enabled = True + mock_settings.MAGIC_LINK_TOKEN_EXPIRE_MINUTES = 15 + mock_settings.SECRET_KEY = settings.SECRET_KEY + mock_settings.FRONTEND_HOST = "http://localhost:3000" + mock_settings.PROJECT_NAME = "Kaapi" + + resp = client.post(MAGIC_LINK_URL, json={"email": user.email}) + assert resp.status_code == 200 + assert "login link has been sent" in resp.json()["data"]["message"] + mock_send.assert_called_once() + + +class TestMagicLinkVerify: + """Test suite for GET /auth/magic-link/verify endpoint.""" + + def test_verify_invalid_token(self, client: TestClient): + """Test returns 400 for invalid token.""" + resp = client.get(f"{MAGIC_LINK_VERIFY_URL}?token=invalid.token.here") + assert resp.status_code == 400 + assert "expired" in resp.json()["error"] or "Invalid" in resp.json()["error"] + + def test_verify_expired_token(self, db: Session, client: TestClient): + """Test returns 400 for expired magic link token.""" + user = create_random_user(db) + with patch("app.services.auth.settings.MAGIC_LINK_TOKEN_EXPIRE_MINUTES", -1): + token = generate_magic_link_token(email=user.email) + + resp = client.get(f"{MAGIC_LINK_VERIFY_URL}?token={token}") + assert resp.status_code == 400 + + def test_verify_user_not_found(self, client: TestClient): + """Test returns 404 when user doesn't exist.""" + token = generate_magic_link_token(email="ghost@example.com") + resp = client.get(f"{MAGIC_LINK_VERIFY_URL}?token={token}") + assert resp.status_code == 404 + + def test_verify_activates_inactive_user(self, db: Session, client: TestClient): + """Test magic link verify activates inactive user.""" + user = create_random_user(db) + user.is_active = False + db.add(user) + db.commit() + db.refresh(user) + + token = generate_magic_link_token(email=user.email) + resp = client.get(f"{MAGIC_LINK_VERIFY_URL}?token={token}") + assert resp.status_code == 200 + + db.refresh(user) + assert user.is_active is True + + def test_verify_success(self, db: Session, client: TestClient): + """Test successful magic link verification logs user in.""" + user = create_random_user(db) + token = generate_magic_link_token(email=user.email) + + resp = client.get(f"{MAGIC_LINK_VERIFY_URL}?token={token}") + assert resp.status_code == 200 + assert resp.json()["success"] is True + assert "access_token" in resp.json()["data"] + assert "access_token" in resp.cookies + + class TestInviteVerify: """Test suite for GET /auth/invite/verify endpoint.""" @@ -373,6 +485,31 @@ def test_generate_and_verify_invite_token(self): assert result.organization_id == 1 assert result.project_id == 2 + def test_verify_invite_token_wrong_type(self): + """Test invite verify rejects magic_link tokens.""" + token = generate_magic_link_token(email="test@example.com") + result = verify_invite_token(token) + assert result is None + + def test_generate_and_verify_magic_link_token(self): + """Test magic link token roundtrip.""" + token = generate_magic_link_token(email="test@example.com") + result = verify_magic_link_token(token) + assert result == "test@example.com" + + def test_verify_magic_link_token_wrong_type(self): + """Test magic link verify rejects invite tokens.""" + token = generate_invite_token( + email="test@example.com", organization_id=1, project_id=1 + ) + result = verify_magic_link_token(token) + assert result is None + + def test_verify_invalid_token_returns_none(self): + """Test both verify functions return None for garbage tokens.""" + assert verify_invite_token("garbage") is None + assert verify_magic_link_token("garbage") is None + 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/utils.py b/backend/app/utils.py index 03818104d..0d9741a5f 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -205,6 +205,22 @@ def generate_invite_email( return EmailData(html_content=html_content, subject=subject) +def generate_magic_link_email(*, email_to: str, magic_link_token: str) -> EmailData: + app_name = settings.PROJECT_NAME + subject = f"{app_name} - Sign in to your account" + link = f"{settings.FRONTEND_HOST}/verify?token={magic_link_token}" + html_content = render_email_template( + template_name="magic_link_login.html", + context={ + "app_name": app_name, + "email": email_to, + "link": link, + "valid_minutes": settings.MAGIC_LINK_TOKEN_EXPIRE_MINUTES, + }, + ) + return EmailData(html_content=html_content, subject=subject) + + def generate_password_reset_token(email: str) -> str: return security.encode_jwt_token( subject=email,