From 342406b3643c318d9460de2fb871cdf2ff95b19d Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Fri, 10 Apr 2026 12:24:58 +0500 Subject: [PATCH 1/3] feat: add TrelloToken model and User.trello_token relationship --- libs/common/models.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/libs/common/models.py b/libs/common/models.py index da4163a..3c9761d 100644 --- a/libs/common/models.py +++ b/libs/common/models.py @@ -56,6 +56,20 @@ class NotionToken(Base): user = relationship("User", back_populates="notion_token") +class TrelloToken(Base): + __tablename__ = "trello_tokens" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), unique=True, index=True) + access_token = Column(Text) # oauth_token (per-user Trello access token) + token_secret = Column(Text) # oauth_token_secret (per-user) + trello_user_id = Column(String) # Trello member ID + username = Column(String, nullable=True) # @username for display + selected_board_id = Column(String, nullable=True) # persisted board selection + + user = relationship("User", back_populates="trello_token") + + class ResetToken(Base): __tablename__ = "reset_tokens" @@ -117,6 +131,9 @@ class User(Base): # Relationship to Notion token (one-to-one) notion_token = relationship("NotionToken", back_populates="user", uselist=False) + # Relationship to Trello token (one-to-one) + trello_token = relationship("TrelloToken", back_populates="user", uselist=False) + # Relationship to GitHub installations github_installations = relationship("GitHubInstallation", back_populates="user") From 10d2109fc26914badae85306ce032955d77b87af Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Fri, 10 Apr 2026 12:27:33 +0500 Subject: [PATCH 2/3] feat: migrate trello_tokens table, drop old user_tokens trello columns --- .../dde1a0ded9f7_add_trello_tokens_table.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 alembic/versions/dde1a0ded9f7_add_trello_tokens_table.py diff --git a/alembic/versions/dde1a0ded9f7_add_trello_tokens_table.py b/alembic/versions/dde1a0ded9f7_add_trello_tokens_table.py new file mode 100644 index 0000000..6257446 --- /dev/null +++ b/alembic/versions/dde1a0ded9f7_add_trello_tokens_table.py @@ -0,0 +1,49 @@ +"""add_trello_tokens_table + +Revision ID: dde1a0ded9f7 +Revises: b0c1d2e3f4a5 +Create Date: 2026-04-10 12:25:24.527658 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "dde1a0ded9f7" +down_revision: str | Sequence[str] | None = "b0c1d2e3f4a5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create trello_tokens table; remove legacy trello columns from user_tokens.""" + op.create_table( + "trello_tokens", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id"), + unique=True, + index=True, + nullable=False, + ), + sa.Column("access_token", sa.Text(), nullable=False), + sa.Column("token_secret", sa.Text(), nullable=False), + sa.Column("trello_user_id", sa.String(), nullable=False), + sa.Column("username", sa.String(), nullable=True), + sa.Column("selected_board_id", sa.String(), nullable=True), + ) + op.drop_column("user_tokens", "trello_workspace_id") + op.drop_column("user_tokens", "trello_board_id") + + +def downgrade() -> None: + """Drop trello_tokens table; restore legacy trello columns on user_tokens.""" + op.drop_table("trello_tokens") + op.add_column("user_tokens", sa.Column("trello_workspace_id", sa.String(), nullable=True)) + op.add_column("user_tokens", sa.Column("trello_board_id", sa.String(), nullable=True)) From 913c015204f8ae5d7af47ddec6ce94419c8a55fd Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Fri, 10 Apr 2026 17:17:20 +0500 Subject: [PATCH 3/3] Trello integration fix --- .../jira/routes/api_routes/trello.py | 51 +-- .../jira/routes/auth_routes/__init__.py | 2 + .../jira/routes/auth_routes/oauth_trello.py | 305 ++++++++++++++++++ .../jira/routes/auth_routes/profile.py | 8 +- apps/integrations/jira/trello_client.py | 22 +- docker-compose.yml | 3 + libs/common/models.py | 8 +- tests/unit/auth/test_trello_oauth.py | 214 ++++++++++++ 8 files changed, 576 insertions(+), 37 deletions(-) create mode 100644 apps/integrations/jira/routes/auth_routes/oauth_trello.py create mode 100644 tests/unit/auth/test_trello_oauth.py diff --git a/apps/integrations/jira/routes/api_routes/trello.py b/apps/integrations/jira/routes/api_routes/trello.py index 7133658..4ee31fe 100644 --- a/apps/integrations/jira/routes/api_routes/trello.py +++ b/apps/integrations/jira/routes/api_routes/trello.py @@ -1,4 +1,5 @@ import logging +import os from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends, Header, HTTPException @@ -91,25 +92,20 @@ async def _get_trello_client_core(authorization: str, db: AsyncSession) -> Trell raise HTTPException(status_code=401, detail="Invalid token") result = await db.execute( - select(User).where(User.email == email).options(selectinload(User.jira_token)) + select(User).where(User.email == email).options(selectinload(User.trello_token)) ) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=401, detail="User not found") - if not user.jira_token: - raise HTTPException(status_code=404, detail="Jira not connected") + if not user.trello_token: + raise HTTPException(status_code=404, detail="Trello not connected") - if not user.jira_token.trello_workspace_id: - raise HTTPException( - status_code=404, - detail="Trello not connected. Reconnect Jira to grant Trello access.", - ) + api_key = os.getenv("TRELLO_API_KEY") + if not api_key: + raise HTTPException(status_code=500, detail="Trello API key not configured") - return TrelloClient( - access_token=user.jira_token.access_token, - workspace_id=user.jira_token.trello_workspace_id, - ) + return TrelloClient(api_key=api_key, user_token=user.trello_token.access_token) except HTTPException: raise except jwt.JWTError as e: @@ -130,7 +126,7 @@ async def _get_current_user(authorization: str, db: AsyncSession) -> User: payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM]) email = payload.get("sub") result = await db.execute( - select(User).where(User.email == email).options(selectinload(User.jira_token)) + select(User).where(User.email == email).options(selectinload(User.trello_token)) ) return result.scalar_one_or_none() @@ -161,11 +157,22 @@ class MoveCardRequest(BaseModel): @router.get("/trello/boards") -async def list_trello_boards(client: TrelloClient = Depends(get_trello_client)): - """Return all open boards for the authenticated Trello user.""" +async def list_trello_boards( + authorization: str = Header(None), + db: AsyncSession = Depends(get_async_db), + client: TrelloClient = Depends(get_trello_client), +): + """Return all open boards + the user's persisted board selection.""" try: boards = await client.get_boards() - return {"boards": [{"id": b["id"], "name": b["name"]} for b in boards]} + user = await _get_current_user(authorization, db) + selected_board_id = ( + user.trello_token.selected_board_id if user and user.trello_token else None + ) + return { + "boards": [{"id": b["id"], "name": b["name"]} for b in boards], + "selected_board_id": selected_board_id, + } except TrelloAuthError as e: raise HTTPException(status_code=401, detail=str(e)) from e except Exception as e: @@ -181,9 +188,9 @@ async def select_trello_board( ): """Persist the user's selected Trello board.""" user = await _get_current_user(authorization, db) - if not user or not user.jira_token: + if not user or not user.trello_token: raise HTTPException(status_code=404, detail="Trello not connected") - user.jira_token.trello_board_id = board_id + user.trello_token.selected_board_id = board_id await db.commit() return {"status": "ok", "board_id": board_id} @@ -199,7 +206,7 @@ async def get_trello_stats( try: user = await _get_current_user(authorization, db) resolved_board_id = board_id or ( - user.jira_token.trello_board_id if user and user.jira_token else None + user.trello_token.selected_board_id if user and user.trello_token else None ) if not resolved_board_id: raise HTTPException(status_code=400, detail="No board selected") @@ -208,12 +215,15 @@ async def get_trello_stats( lists = board_data.get("lists", []) cards = board_data.get("cards", []) - # Build list_id → (name, category) mapping + # Build list_id → (name, category) mapping + first list ID per category for moves total = len(lists) list_meta: dict[str, dict] = {} + category_list_ids: dict[str, str] = {} # first list ID per category (for card moves) for i, lst in enumerate(lists): cat = _classify_list(lst["name"], i, total) list_meta[lst["id"]] = {"name": lst["name"], "category": cat} + if cat not in category_list_ids: + category_list_ids[cat] = lst["id"] board: dict = {"todo": [], "inProgress": [], "done": []} now = datetime.now(UTC) @@ -267,6 +277,7 @@ async def get_trello_stats( "overdue": str(overdue_count), "highPriority": str(high_priority_count), "board": board, + "list_ids": category_list_ids, # {todo: "listId", inProgress: "listId", done: "listId"} "filters": { "priorities": ["High", "Medium", "Low"], "types": ["Card"], diff --git a/apps/integrations/jira/routes/auth_routes/__init__.py b/apps/integrations/jira/routes/auth_routes/__init__.py index b23bc40..a9d98a9 100644 --- a/apps/integrations/jira/routes/auth_routes/__init__.py +++ b/apps/integrations/jira/routes/auth_routes/__init__.py @@ -4,6 +4,7 @@ from apps.integrations.jira.routes.auth_routes.oauth_github import router as github_router from apps.integrations.jira.routes.auth_routes.oauth_google import router as google_router from apps.integrations.jira.routes.auth_routes.oauth_jira import router as jira_router +from apps.integrations.jira.routes.auth_routes.oauth_trello import router as trello_router from apps.integrations.jira.routes.auth_routes.password import router as password_router from apps.integrations.jira.routes.auth_routes.profile import router as profile_router from apps.integrations.jira.routes.auth_routes.waitlist import router as waitlist_router @@ -14,6 +15,7 @@ router.include_router(github_router) router.include_router(google_router) router.include_router(jira_router) +router.include_router(trello_router) router.include_router(profile_router) router.include_router(password_router) router.include_router(waitlist_router) diff --git a/apps/integrations/jira/routes/auth_routes/oauth_trello.py b/apps/integrations/jira/routes/auth_routes/oauth_trello.py new file mode 100644 index 0000000..f33fe85 --- /dev/null +++ b/apps/integrations/jira/routes/auth_routes/oauth_trello.py @@ -0,0 +1,305 @@ +""" +Trello OAuth 1.0a authentication routes. + +Endpoints: + GET /trello/connect – Start OAuth flow; returns Trello authorize URL + GET /trello/callback – Handle Trello redirect; upsert TrelloToken; redirect to frontend + DELETE /trello/disconnect – Delete stored TrelloToken for the current user +""" + +import base64 +import hashlib +import hmac +import json +import logging +import os +import time +import uuid +from urllib.parse import parse_qs, quote + +import httpx +from fastapi import APIRouter, Depends, Header, HTTPException +from fastapi.responses import RedirectResponse +from jose import jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from apps.integrations.jira.routes.api_routes.shared import ALGORITHM, get_secret_key +from libs.common.database import get_async_db +from libs.common.models import TrelloToken, User + +logger = logging.getLogger(__name__) +router = APIRouter() + +_TRELLO_REQUEST_TOKEN_URL = "https://trello.com/1/OAuthGetRequestToken" +_TRELLO_ACCESS_TOKEN_URL = "https://trello.com/1/OAuthGetAccessToken" +_TRELLO_AUTHORIZE_URL = "https://trello.com/1/OAuthAuthorizeToken" +_TRELLO_MEMBERS_ME_URL = "https://api.trello.com/1/members/me" + + +# ─── OAuth 1.0a helpers ─────────────────────────────────────────────────────── + + +def _build_oauth_header( + method: str, + url: str, + consumer_key: str, + consumer_secret: str, + token: str = "", + token_secret: str = "", + extra_params: dict | None = None, +) -> str: + """Return an OAuth 1.0a Authorization header value using HMAC-SHA1 signing.""" + timestamp = str(int(time.time())) + nonce = uuid.uuid4().hex + + oauth_params: dict[str, str] = { + "oauth_consumer_key": consumer_key, + "oauth_nonce": nonce, + "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": timestamp, + "oauth_version": "1.0", + } + if token: + oauth_params["oauth_token"] = token + + # Merge oauth params with any extra query/body params for signing + all_params = {**oauth_params, **(extra_params or {})} + + # Normalized parameter string: percent-encode each key/value, sort, join with & + normalized = "&".join( + f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in sorted(all_params.items()) + ) + + # Signature base string: METHOD & percent(url) & percent(normalized_params) + base_string = method.upper() + "&" + quote(url, safe="") + "&" + quote(normalized, safe="") + + # Signing key: percent(consumer_secret) & percent(token_secret) + signing_key = f"{quote(consumer_secret, safe='')}&{quote(token_secret, safe='')}" + + digest = hmac.new(signing_key.encode(), base_string.encode(), hashlib.sha1).digest() + signature = base64.b64encode(digest).decode() + + # Percent-encode signature value before placing inside header quotes + oauth_params["oauth_signature"] = quote(signature, safe="") + header_parts = ", ".join(f'{k}="{v}"' for k, v in sorted(oauth_params.items())) + return f"OAuth {header_parts}" + + +def _get_redis(): + """Return an async Redis client (decode_responses=True).""" + import redis.asyncio as aioredis + + url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + return aioredis.from_url(url, decode_responses=True) + + +async def _get_request_token(callback_url: str) -> tuple[str, str]: + """POST to Trello OAuthGetRequestToken; return (oauth_token, oauth_token_secret).""" + api_key = os.getenv("TRELLO_API_KEY", "") + api_secret = os.getenv("TRELLO_API_SECRET", "") + + auth_header = _build_oauth_header( + "POST", + _TRELLO_REQUEST_TOKEN_URL, + consumer_key=api_key, + consumer_secret=api_secret, + extra_params={"oauth_callback": callback_url}, + ) + async with httpx.AsyncClient() as client: + resp = await client.post( + _TRELLO_REQUEST_TOKEN_URL, + headers={"Authorization": auth_header}, + params={"oauth_callback": callback_url}, + ) + resp.raise_for_status() + + parsed = parse_qs(resp.text) + return parsed["oauth_token"][0], parsed["oauth_token_secret"][0] + + +async def _get_access_token( + oauth_token: str, + oauth_verifier: str, + request_secret: str, +) -> tuple[str, str]: + """POST to Trello OAuthGetAccessToken; return (access_token, token_secret).""" + api_key = os.getenv("TRELLO_API_KEY", "") + api_secret = os.getenv("TRELLO_API_SECRET", "") + + auth_header = _build_oauth_header( + "POST", + _TRELLO_ACCESS_TOKEN_URL, + consumer_key=api_key, + consumer_secret=api_secret, + token=oauth_token, + token_secret=request_secret, + extra_params={"oauth_verifier": oauth_verifier}, + ) + async with httpx.AsyncClient() as client: + resp = await client.post( + _TRELLO_ACCESS_TOKEN_URL, + headers={"Authorization": auth_header}, + params={"oauth_verifier": oauth_verifier}, + ) + resp.raise_for_status() + + parsed = parse_qs(resp.text) + return parsed["oauth_token"][0], parsed["oauth_token_secret"][0] + + +# ─── Routes ────────────────────────────────────────────────────────────────── + + +@router.get("/trello/connect") +async def trello_connect(authorization: str = Header(None)): + """Start Trello OAuth 1.0a. Returns {url} to open in a popup.""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Unauthorized") + + raw_jwt = authorization.split(" ", 1)[1] + callback_url = os.getenv( + "TRELLO_REDIRECT_URI", "https://api.kwillo.live/api/auth/trello/callback" + ) + + try: + req_token, req_secret = await _get_request_token(callback_url) + except Exception as e: + logger.exception("Failed to get Trello request token") + raise HTTPException(status_code=502, detail="Failed to contact Trello") from e + + redis = _get_redis() + try: + state = json.dumps({"secret": req_secret, "jwt": raw_jwt}) + await redis.set(f"trello_oauth:{req_token}", state, ex=300) + finally: + await redis.aclose() + + authorize_url = ( + f"{_TRELLO_AUTHORIZE_URL}?oauth_token={req_token}" + "&name=Kwillo&expiration=never&scope=read,write" + ) + return {"url": authorize_url} + + +@router.get("/trello/callback") +async def trello_callback( + oauth_token: str, + oauth_verifier: str, + db: AsyncSession = Depends(get_async_db), +): + """Handle Trello OAuth callback: exchange tokens, upsert TrelloToken, redirect.""" + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173") + + redis = _get_redis() + try: + raw = await redis.get(f"trello_oauth:{oauth_token}") + finally: + await redis.aclose() + + if not raw: + raise HTTPException(status_code=400, detail="OAuth session expired, please try again") + + state = json.loads(raw) + request_secret: str = state["secret"] + raw_jwt: str = state["jwt"] + + try: + access_token, token_secret = await _get_access_token( + oauth_token, oauth_verifier, request_secret + ) + except Exception as e: + logger.exception("Failed to exchange Trello access token") + raise HTTPException(status_code=502, detail="Failed to exchange Trello token") from e + + # Fetch Trello member identity + api_key = os.getenv("TRELLO_API_KEY", "") + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + _TRELLO_MEMBERS_ME_URL, + params={"key": api_key, "token": access_token, "fields": "id,username"}, + ) + resp.raise_for_status() + member = resp.json() + except Exception as e: + logger.exception("Failed to fetch Trello member info") + raise HTTPException(status_code=502, detail="Failed to fetch Trello user info") from e + + trello_user_id: str = member["id"] + username: str | None = member.get("username") + + # Identify the Kwillo user from the JWT stored in Redis + try: + secret_key = get_secret_key() + payload = jwt.decode(raw_jwt, secret_key, algorithms=[ALGORITHM]) + email = payload.get("sub") + if not email: + raise HTTPException(status_code=400, detail="Invalid session token") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail="Invalid session token") from e + + result = await db.execute( + select(User).where(User.email == email).options(selectinload(User.trello_token)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.trello_token: + user.trello_token.access_token = access_token + user.trello_token.token_secret = token_secret + user.trello_token.trello_user_id = trello_user_id + user.trello_token.username = username + else: + db.add( + TrelloToken( + user_id=user.id, + access_token=access_token, + token_secret=token_secret, + trello_user_id=trello_user_id, + username=username, + ) + ) + + await db.commit() + + return RedirectResponse(f"{frontend_url}/auth/callback?trello_connected=true&token={raw_jwt}") + + +@router.delete("/trello/disconnect") +async def trello_disconnect( + db: AsyncSession = Depends(get_async_db), + authorization: str = Header(None), +): + """Delete the TrelloToken for the current user.""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Unauthorized") + + token = authorization.split(" ", 1)[1] + try: + secret_key = get_secret_key() + payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM]) + email = payload.get("sub") + if not email: + raise HTTPException(status_code=401, detail="Invalid token") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=401, detail="Invalid token") from e + + result = await db.execute( + select(User).where(User.email == email).options(selectinload(User.trello_token)) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found") + + if user.trello_token: + await db.delete(user.trello_token) + await db.commit() + + return {"status": "success"} diff --git a/apps/integrations/jira/routes/auth_routes/profile.py b/apps/integrations/jira/routes/auth_routes/profile.py index 25a1416..85c3071 100644 --- a/apps/integrations/jira/routes/auth_routes/profile.py +++ b/apps/integrations/jira/routes/auth_routes/profile.py @@ -33,14 +33,18 @@ async def get_me(db: AsyncSession = Depends(get_async_db), authorization: str = _result = await db.execute( select(User) .where(User.email == email) - .options(selectinload(User.jira_token), selectinload(User.notion_token)) + .options( + selectinload(User.jira_token), + selectinload(User.notion_token), + selectinload(User.trello_token), + ) ) user = _result.scalar_one_or_none() if not user: raise HTTPException(status_code=401, detail="User not found") jira_connected = user.jira_token is not None - trello_connected = bool(user.jira_token and user.jira_token.trello_workspace_id) + trello_connected = user.trello_token is not None return { "id": user.id, diff --git a/apps/integrations/jira/trello_client.py b/apps/integrations/jira/trello_client.py index 20476a6..a777178 100644 --- a/apps/integrations/jira/trello_client.py +++ b/apps/integrations/jira/trello_client.py @@ -8,24 +8,24 @@ class TrelloClient: - """Async Trello API client using the same Atlassian OAuth access token as Jira.""" - - def __init__(self, access_token: str, workspace_id: str): - self.access_token = access_token - self.workspace_id = workspace_id - self._client = httpx.AsyncClient( - headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"}, - timeout=30.0, - ) + """Async Trello API client using per-user OAuth 1.0a token + app API key.""" + + def __init__(self, api_key: str, user_token: str): + self.api_key = api_key + self.user_token = user_token + self._client = httpx.AsyncClient(timeout=30.0) async def aclose(self): await self._client.aclose() async def _request(self, method: str, path: str, **kwargs) -> dict | list: url = f"{TRELLO_API_BASE}{path}" - resp = await self._client.request(method, url, **kwargs) + params = kwargs.pop("params", {}) + params["key"] = self.api_key + params["token"] = self.user_token + resp = await self._client.request(method, url, params=params, **kwargs) if resp.status_code == 401: - raise TrelloAuthError("Trello access token is invalid or expired") + raise TrelloAuthError("Trello token is invalid or revoked") resp.raise_for_status() return resp.json() diff --git a/docker-compose.yml b/docker-compose.yml index f8947ba..e2d813a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -123,6 +123,9 @@ services: - NOTION_CLIENT_ID=${NOTION_CLIENT_ID} - NOTION_CLIENT_SECRET=${NOTION_CLIENT_SECRET} - NOTION_REDIRECT_URI=${NOTION_REDIRECT_URI} + - TRELLO_API_KEY=${TRELLO_API_KEY} + - TRELLO_API_SECRET=${TRELLO_API_SECRET} + - TRELLO_REDIRECT_URI=${TRELLO_REDIRECT_URI:-https://api.kwillo.live/api/auth/trello/callback} - S3_BUCKET_NAME=${S3_BUCKET_NAME:-} - S3_REGION=${S3_REGION:-} - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} diff --git a/libs/common/models.py b/libs/common/models.py index 3c9761d..3505082 100644 --- a/libs/common/models.py +++ b/libs/common/models.py @@ -61,10 +61,10 @@ class TrelloToken(Base): id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), unique=True, index=True) - access_token = Column(Text) # oauth_token (per-user Trello access token) - token_secret = Column(Text) # oauth_token_secret (per-user) - trello_user_id = Column(String) # Trello member ID - username = Column(String, nullable=True) # @username for display + access_token = Column(Text) # oauth_token (per-user Trello access token) + token_secret = Column(Text) # oauth_token_secret (per-user) + trello_user_id = Column(String) # Trello member ID + username = Column(String, nullable=True) # @username for display selected_board_id = Column(String, nullable=True) # persisted board selection user = relationship("User", back_populates="trello_token") diff --git a/tests/unit/auth/test_trello_oauth.py b/tests/unit/auth/test_trello_oauth.py new file mode 100644 index 0000000..5d13ad9 --- /dev/null +++ b/tests/unit/auth/test_trello_oauth.py @@ -0,0 +1,214 @@ +""" +Unit tests for Trello OAuth 1.0a signing helpers and route logic in +apps/integrations/jira/routes/auth_routes/oauth_trello.py. + +The module exposes: + _build_oauth_header(method, url, consumer_key, consumer_secret, + token, token_secret, extra_params) -> str + router: APIRouter (connect / callback / disconnect) +""" + +import base64 +import json +import re +from unittest.mock import AsyncMock, patch +from urllib.parse import unquote + +import pytest +from fastapi.testclient import TestClient + +# ─── OAuth signing tests ────────────────────────────────────────────────────── + + +class TestBuildOAuthHeader: + """Tests for _build_oauth_header — the core OAuth 1.0a signing function.""" + + def _import(self): + from apps.integrations.jira.routes.auth_routes.oauth_trello import ( + _build_oauth_header, + ) + + return _build_oauth_header + + def test_returns_string_starting_with_oauth(self): + fn = self._import() + header = fn("GET", "https://trello.com/1/test", "key", "secret") + assert header.startswith("OAuth ") + + def test_contains_required_oauth_fields(self): + fn = self._import() + header = fn("GET", "https://trello.com/1/test", "mykey", "mysecret") + assert "oauth_consumer_key" in header + assert "oauth_nonce" in header + assert "oauth_signature_method" in header + assert "oauth_timestamp" in header + assert "oauth_version" in header + assert "oauth_signature" in header + + def test_signature_method_is_hmac_sha1(self): + fn = self._import() + header = fn("GET", "https://trello.com/1/test", "key", "secret") + assert 'oauth_signature_method="HMAC-SHA1"' in header + + def test_version_is_1_0(self): + fn = self._import() + header = fn("GET", "https://trello.com/1/test", "key", "secret") + assert 'oauth_version="1.0"' in header + + def test_consumer_key_is_embedded(self): + fn = self._import() + header = fn("GET", "https://trello.com/1/test", "MYAPPKEY", "secret") + assert "MYAPPKEY" in header + + def test_token_included_when_provided(self): + fn = self._import() + header = fn("GET", "https://trello.com/1/test", "key", "secret", token="USER_TOKEN") + assert "USER_TOKEN" in header + + def test_token_omitted_when_empty(self): + fn = self._import() + header = fn("GET", "https://trello.com/1/test", "key", "secret", token="") + assert "oauth_token" not in header + + def test_two_calls_produce_different_nonces(self): + """Each call must generate a unique nonce.""" + fn = self._import() + h1 = fn("GET", "https://trello.com/1/test", "key", "secret") + h2 = fn("GET", "https://trello.com/1/test", "key", "secret") + + def extract_nonce(h): + m = re.search(r'oauth_nonce="([^"]+)"', h) + return m.group(1) if m else None + + assert extract_nonce(h1) != extract_nonce(h2) + + def test_signature_is_valid_base64_sha1_digest(self): + """Signature must be base64-encoded 20-byte SHA1 digest.""" + fn = self._import() + header = fn("POST", "https://trello.com/1/OAuthGetRequestToken", "testkey", "testsecret") + m = re.search(r'oauth_signature="([^"]+)"', header) + assert m is not None + sig_encoded = unquote(m.group(1)) + decoded = base64.b64decode(sig_encoded) + assert len(decoded) == 20 # SHA1 digest is always 20 bytes + + +# ─── Connect route tests ────────────────────────────────────────────────────── + + +class TestTrelloConnectRoute: + """Tests for GET /trello/connect.""" + + def _make_client(self): + from fastapi import FastAPI + + from apps.integrations.jira.routes.auth_routes.oauth_trello import router + + app = FastAPI() + app.include_router(router) + return TestClient(app) + + def test_returns_401_without_authorization_header(self): + client = self._make_client() + resp = client.get("/trello/connect") + assert resp.status_code == 401 + + def test_returns_401_without_bearer_prefix(self): + client = self._make_client() + resp = client.get("/trello/connect", headers={"Authorization": "badtoken"}) + assert resp.status_code == 401 + + @patch( + "apps.integrations.jira.routes.auth_routes.oauth_trello._get_request_token", + new_callable=AsyncMock, + ) + @patch("apps.integrations.jira.routes.auth_routes.oauth_trello._get_redis") + def test_returns_url_on_success(self, mock_get_redis, mock_get_request_token): + mock_get_request_token.return_value = ("req_token_123", "req_secret_abc") + mock_redis = AsyncMock() + mock_redis.set = AsyncMock(return_value=True) + mock_redis.aclose = AsyncMock() + mock_get_redis.return_value = mock_redis + + client = self._make_client() + resp = client.get( + "/trello/connect", + headers={"Authorization": "Bearer some.jwt.token"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert "url" in body + assert "req_token_123" in body["url"] + assert "OAuthAuthorizeToken" in body["url"] + + @patch( + "apps.integrations.jira.routes.auth_routes.oauth_trello._get_request_token", + new_callable=AsyncMock, + ) + @patch("apps.integrations.jira.routes.auth_routes.oauth_trello._get_redis") + def test_stores_state_in_redis(self, mock_get_redis, mock_get_request_token): + mock_get_request_token.return_value = ("tok", "sec") + mock_redis = AsyncMock() + mock_redis.set = AsyncMock() + mock_redis.aclose = AsyncMock() + mock_get_redis.return_value = mock_redis + + client = self._make_client() + client.get("/trello/connect", headers={"Authorization": "Bearer my.jwt"}) + + mock_redis.set.assert_called_once() + call_args = mock_redis.set.call_args + key = call_args[0][0] + value = call_args[0][1] + assert key == "trello_oauth:tok" + stored = json.loads(value) + assert stored["secret"] == "sec" + assert stored["jwt"] == "my.jwt" + + +# ─── Disconnect route tests ─────────────────────────────────────────────────── + + +class TestTrelloDisconnectRoute: + """Tests for DELETE /trello/disconnect.""" + + def _make_client(self): + from fastapi import FastAPI + + from apps.integrations.jira.routes.auth_routes.oauth_trello import router + + app = FastAPI() + app.include_router(router) + return TestClient(app) + + def test_returns_401_without_authorization(self): + client = self._make_client() + resp = client.delete("/trello/disconnect") + assert resp.status_code == 401 + + def test_returns_401_without_bearer_prefix(self): + client = self._make_client() + resp = client.delete("/trello/disconnect", headers={"Authorization": "plain token"}) + assert resp.status_code == 401 + + +# ─── TrelloClient auth tests ────────────────────────────────────────────────── + + +class TestTrelloClientAuth: + """Tests for updated TrelloClient authentication mechanism.""" + + def test_client_stores_api_key_and_user_token(self): + from apps.integrations.jira.trello_client import TrelloClient + + client = TrelloClient(api_key="APP_KEY", user_token="USER_TOKEN") + assert client.api_key == "APP_KEY" + assert client.user_token == "USER_TOKEN" + + def test_client_does_not_store_bearer_header(self): + """No Authorization: Bearer header — auth via query params only.""" + from apps.integrations.jira.trello_client import TrelloClient + + client = TrelloClient(api_key="APP_KEY", user_token="USER_TOKEN") + default_headers = dict(client._client.headers) + assert "authorization" not in {k.lower() for k in default_headers}