diff --git a/app.py b/app.py index 888df9e6..281a418a 100644 --- a/app.py +++ b/app.py @@ -47,6 +47,7 @@ from routes.oauth_routes import router as oauth_router from routes.redirect_routes import router as redirect_router from routes.static_routes import router as static_router +from shared.app_registry import load_app_registry from shared.logging import get_logger from shared.templates import configure_template_globals, templates @@ -124,6 +125,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await ensure_indexes(app.state.db) + # App registry for the consent/apps system + app.state.app_registry = load_app_registry() + # Warn if session secret is missing when auth is enabled if settings.jwt and not settings.secret_key: log.warning( diff --git a/config.py b/config.py index 3557c17e..5cb8538a 100644 --- a/config.py +++ b/config.py @@ -125,10 +125,6 @@ class AppSettings(BaseSettings): cors_origins: list[str] = ["*"] # deprecated — kept for backward compat cors_private_origins: list[str] = [] - # Device auth flow - allowed redirect URIs for third-party clients - # (e.g., mobile apps, desktop apps). Browser extensions don't need this. - device_auth_redirect_uris: list[str] = [] - # Request body size limit (bytes); 1 MB default max_content_length: int = 1_048_576 diff --git a/config/apps.yaml b/config/apps.yaml new file mode 100644 index 00000000..275ccb35 --- /dev/null +++ b/config/apps.yaml @@ -0,0 +1,124 @@ +# App registry for the spoo.me ecosystem. +# +# Loaded at application startup and cached in app.state.app_registry. +# All apps listed here appear on the dashboard Apps page. +# Only 'live' + 'device_auth' apps can initiate the consent flow. + +apps: + spoo-snap: + name: Spoo Snap + icon: spoo-snap.svg + description: Official browser extension for spoo.me + verified: true + status: live + type: device_auth + redirect_uris: [] + links: + chrome: https://chrome.google.com/webstore/detail/spoo-snap + firefox: https://addons.mozilla.org/en-US/firefox/addon/spoo-snap + permissions: + - Access your spoo.me account + - Create and manage short URLs + - View your analytics + + spoo-desktop: + name: Spoo Desktop + icon: spoo-desktop.svg + description: Official Windows desktop app + verified: true + status: live + type: device_auth + redirect_uris: + - http://localhost:9274/callback + links: + windows: https://apps.microsoft.com/detail/9mtwpjxlb0gr + permissions: + - Access your spoo.me account + - Create and manage short URLs + + spoo-discord: + name: Discord Bot + icon: discord-bot.svg + description: Shorten links directly in Discord + verified: true + status: live + type: device_auth + redirect_uris: + - https://discord-bot.spoo.me/callback + links: + invite: https://spoo.me/discord-bot + permissions: + - Access your spoo.me account + - Create short URLs on your behalf + + spoo-mobile: + name: Spoo Mobile + icon: spoo-mobile.svg + description: Shorten links on the go + verified: true + status: coming_soon + type: device_auth + + spoo-telegram: + name: Telegram Bot + icon: telegram-bot.svg + description: Shorten links in Telegram chats + verified: true + status: coming_soon + type: device_auth + + spoo-slack: + name: Slack Bot + icon: slack-bot.svg + description: Shorten links in Slack with /shorten + verified: true + status: coming_soon + type: device_auth + + spoo-raycast: + name: Raycast Extension + icon: raycast.svg + description: Shorten links from Raycast + verified: true + status: coming_soon + type: device_auth + + spoo-vscode: + name: VS Code Extension + icon: vscode.svg + description: Shorten links from your editor + verified: true + status: coming_soon + type: device_auth + + spoo-mcp: + name: MCP Server + icon: mcp.svg + description: AI tool server for Claude, Cursor, and others + verified: true + status: coming_soon + type: device_auth + + spoo-zapier: + name: Zapier + icon: zapier.svg + description: Shorten links in no-code workflows + verified: true + status: coming_soon + type: device_auth + + spoo-n8n: + name: n8n + icon: n8n.svg + description: Self-hosted automation for link shortening + verified: true + status: coming_soon + type: device_auth + + spoo-cli: + name: Spoo CLI + icon: spoo-cli.svg + description: Shorten links from your terminal + verified: true + status: coming_soon + type: device_auth diff --git a/dependencies/__init__.py b/dependencies/__init__.py index 8e776cd3..1ae21264 100644 --- a/dependencies/__init__.py +++ b/dependencies/__init__.py @@ -36,6 +36,7 @@ ) from dependencies.services import ( get_api_key_service, + get_app_grant_repo, get_auth_service, get_click_service, get_contact_service, @@ -61,6 +62,7 @@ "check_api_key_scope", # services "get_api_key_service", + "get_app_grant_repo", "get_auth_service", "get_click_service", "get_contact_service", diff --git a/dependencies/services.py b/dependencies/services.py index e4da7c97..cf67e590 100644 --- a/dependencies/services.py +++ b/dependencies/services.py @@ -22,6 +22,7 @@ from infrastructure.geoip import GeoIPService from infrastructure.webhook.discord import DiscordWebhookProvider from repositories.api_key_repository import ApiKeyRepository +from repositories.app_grant_repository import AppGrantRepository from repositories.blocked_url_repository import BlockedUrlRepository from repositories.click_repository import ClickRepository from repositories.legacy.emoji_url_repository import EmojiUrlRepository @@ -41,6 +42,11 @@ from services.url_service import UrlService +async def get_app_grant_repo(db=Depends(get_db)) -> AppGrantRepository: + """Return an AppGrantRepository for the current request.""" + return AppGrantRepository(db["app-grants"]) + + async def get_url_service( db=Depends(get_db), url_cache: UrlCache = Depends(get_url_cache), diff --git a/repositories/app_grant_repository.py b/repositories/app_grant_repository.py new file mode 100644 index 00000000..50eab75a --- /dev/null +++ b/repositories/app_grant_repository.py @@ -0,0 +1,141 @@ +""" +Repository for the `app-grants` MongoDB collection. + +Tracks user consent grants for registered apps (device auth flow). +Supports soft-delete via revoked_at for analytics and reconnect flows. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from bson import ObjectId +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.errors import PyMongoError + +from schemas.models.app_grant import AppGrantDoc +from shared.logging import get_logger + +log = get_logger(__name__) + + +class AppGrantRepository: + def __init__(self, collection: AsyncCollection) -> None: + self._col = collection + + async def find_active_grant( + self, user_id: ObjectId, app_id: str + ) -> AppGrantDoc | None: + """Find an active (non-revoked) grant for a user and app.""" + try: + doc = await self._col.find_one( + {"user_id": user_id, "app_id": app_id, "revoked_at": None} + ) + return AppGrantDoc.from_mongo(doc) + except PyMongoError as exc: + log.error( + "app_grant_find_active_failed", + user_id=str(user_id), + app_id=app_id, + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def find_active_for_user(self, user_id: ObjectId) -> list[AppGrantDoc]: + """Find all active grants for a user.""" + try: + cursor = self._col.find({"user_id": user_id, "revoked_at": None}) + return [AppGrantDoc.from_mongo(doc) async for doc in cursor] + except PyMongoError as exc: + log.error( + "app_grant_find_active_for_user_failed", + user_id=str(user_id), + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def find_all_for_user(self, user_id: ObjectId) -> list[AppGrantDoc]: + """Find all grants for a user, including revoked.""" + try: + cursor = self._col.find({"user_id": user_id}) + return [AppGrantDoc.from_mongo(doc) async for doc in cursor] + except PyMongoError as exc: + log.error( + "app_grant_find_all_for_user_failed", + user_id=str(user_id), + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def create_or_reactivate(self, user_id: ObjectId, app_id: str) -> AppGrantDoc: + """Create a new grant or reactivate a revoked one. + + Uses upsert: if a document exists for (user_id, app_id), clears + revoked_at and updates granted_at. Otherwise inserts a new document. + """ + now = datetime.now(timezone.utc) + try: + doc = await self._col.find_one_and_update( + {"user_id": user_id, "app_id": app_id}, + { + "$set": { + "granted_at": now, + "revoked_at": None, + }, + "$setOnInsert": { + "user_id": user_id, + "app_id": app_id, + "last_used_at": None, + }, + }, + upsert=True, + return_document=True, + ) + return AppGrantDoc.from_mongo(doc) # type: ignore[return-value] + except PyMongoError as exc: + log.error( + "app_grant_create_or_reactivate_failed", + user_id=str(user_id), + app_id=app_id, + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def revoke(self, user_id: ObjectId, app_id: str) -> bool: + """Soft-delete a grant by setting revoked_at. Returns True if a grant was revoked.""" + try: + result = await self._col.update_one( + {"user_id": user_id, "app_id": app_id, "revoked_at": None}, + {"$set": {"revoked_at": datetime.now(timezone.utc)}}, + ) + return result.modified_count > 0 + except PyMongoError as exc: + log.error( + "app_grant_revoke_failed", + user_id=str(user_id), + app_id=app_id, + error=str(exc), + error_type=type(exc).__name__, + ) + raise + + async def touch_last_used(self, user_id: ObjectId, app_id: str) -> None: + """Update last_used_at on an active grant.""" + try: + await self._col.update_one( + {"user_id": user_id, "app_id": app_id, "revoked_at": None}, + {"$set": {"last_used_at": datetime.now(timezone.utc)}}, + ) + except PyMongoError as exc: + log.error( + "app_grant_touch_last_used_failed", + user_id=str(user_id), + app_id=app_id, + error=str(exc), + error_type=type(exc).__name__, + ) + raise diff --git a/repositories/indexes.py b/repositories/indexes.py index 9fd39dd5..d07064d3 100644 --- a/repositories/indexes.py +++ b/repositories/indexes.py @@ -87,4 +87,10 @@ async def ensure_indexes(db: AsyncDatabase) -> None: name="ix_latest_unused_by_user", ) + # ── app-grants ───────────────────────────────────────────────────── + app_grants_col = db["app-grants"] + await app_grants_col.create_index([("user_id", 1), ("app_id", 1)], unique=True) + await app_grants_col.create_index([("user_id", 1), ("revoked_at", 1)]) + await app_grants_col.create_index([("app_id", 1), ("revoked_at", 1)]) + log.info("mongodb_indexes_ensured") diff --git a/repositories/token_repository.py b/repositories/token_repository.py index a8f61451..bfed7935 100644 --- a/repositories/token_repository.py +++ b/repositories/token_repository.py @@ -1,7 +1,7 @@ """ Repository for the `verification-tokens` MongoDB collection. -Tokens are used for email verification, password resets, and OTP flows. +Tokens are used for email verification, password resets, OTP flows, and device auth codes. All methods are async. Returns VerificationTokenDoc models where applicable. Errors are logged and re-raised — the service layer decides recovery. """ @@ -153,16 +153,21 @@ async def increment_attempts(self, token_id: ObjectId) -> bool: raise async def delete_by_user( - self, user_id: ObjectId, token_type: str | None = None + self, + user_id: ObjectId, + token_type: str | None = None, + app_id: str | None = None, ) -> int: """ - Delete all tokens for a user, optionally filtered by token type. + Delete all tokens for a user, optionally filtered by token type and app_id. Returns the number of documents deleted. """ query: dict = {"user_id": user_id} if token_type is not None: query["token_type"] = token_type + if app_id is not None: + query["app_id"] = app_id try: result = await self._col.delete_many(query) return result.deleted_count diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 90595d99..399cd3c7 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -15,19 +15,36 @@ POST /auth/verify-email POST /auth/request-password-reset POST /auth/reset-password +GET /auth/device/login → device auth initiation + consent +POST /auth/device/consent → consent form submission +GET /auth/device/callback → code delivery page for extensions +POST /auth/device/token → exchange code for JWT tokens +POST /auth/device/refresh → refresh app tokens (body-based) +POST /auth/device/revoke → revoke app access """ from __future__ import annotations -from fastapi import APIRouter, Depends, Request, Response +import secrets +from urllib.parse import quote, urlencode + +from fastapi import APIRouter, Depends, Form, Request, Response from fastapi.responses import JSONResponse, RedirectResponse -from dependencies import AuthUser, OptionalUser, get_auth_service +from dependencies import ( + AuthUser, + JwtUser, + OptionalUser, + get_app_grant_repo, + get_auth_service, +) from errors import AuthenticationError from middleware.openapi import AUTH_RESPONSES, ERROR_RESPONSES, PUBLIC_SECURITY from middleware.rate_limiter import Limits, limiter +from repositories.app_grant_repository import AppGrantRepository from routes.cookie_helpers import clear_auth_cookies, set_auth_cookies from schemas.dto.requests.auth import ( + DeviceRefreshRequest, DeviceTokenRequest, LoginRequest, RegisterRequest, @@ -37,6 +54,7 @@ VerifyEmailRequest, ) from schemas.dto.responses.auth import ( + DeviceRefreshResponse, DeviceTokenResponse, LoginResponse, LogoutResponse, @@ -48,7 +66,9 @@ VerifyEmailResponse, ) from schemas.dto.responses.common import MessageResponse +from schemas.models.app import AppEntry from services.auth_service import OTP_EXPIRY_SECONDS, AuthService +from shared.generators import generate_secure_token from shared.ip_utils import get_client_ip from shared.logging import get_logger from shared.templates import templates @@ -58,13 +78,6 @@ router = APIRouter(tags=["Authentication"]) -def _validate_redirect_uri(uri: str, allowed: list[str]) -> str | None: - """Return the URI if it's in the allowlist, None otherwise.""" - if not uri or not allowed: - return None - return uri if uri in allowed else None - - # ── Redirect shortcuts ──────────────────────────────────────────────────────── @@ -207,7 +220,7 @@ async def refresh( return resp try: - _user, new_access, new_refresh = await auth_service.refresh_token( + _user, new_access, new_refresh, _app_id = await auth_service.refresh_token( refresh_token_str ) except AuthenticationError as exc: @@ -287,7 +300,7 @@ async def me( async def set_password( request: Request, body: SetPasswordRequest, - user: AuthUser, + user: JwtUser, auth_service: AuthService = Depends(get_auth_service), ) -> MessageResponse: """Set a password for an OAuth-only account. @@ -296,7 +309,7 @@ async def set_password( also log in with email + password. Fails if the user already has a password set. - **Authentication**: Required (JWT or API key) + **Authentication**: Required (JWT only — API keys cannot set passwords) **Rate Limits**: 5/min """ @@ -452,6 +465,45 @@ async def reset_password( # ── Device auth flow (extensions, apps, CLIs) ──────────────────────────────── +_CSRF_COOKIE_NAME = "_consent_csrf" +_CSRF_TTL_SECONDS = 600 +_CSRF_TOKEN_BYTES = 32 +_CSRF_HEADER_NAME = "x-requested-with" +_CSRF_HEADER_VALUE = "fetch" +_APP_ID_MAX_LEN = 64 + + +def _get_device_app(request: Request, app_id: str) -> AppEntry | None: + """Look up an app_id in the registry and return it if it's a live device-auth app.""" + if not app_id or len(app_id) > _APP_ID_MAX_LEN: + return None + registry: dict[str, AppEntry] = request.app.state.app_registry + entry = registry.get(app_id) + return entry if entry and entry.is_live_device_app() else None + + +def _validate_redirect_uri(redirect_uri: str, app: AppEntry) -> bool: + """Return True if redirect_uri is empty or in the app's allowlist.""" + return not redirect_uri or redirect_uri in app.redirect_uris + + +def _device_error(request: Request, error: str, status_code: int = 400) -> Response: + """Render the device auth error page.""" + return templates.TemplateResponse( + request, "device_error.html", {"error": error}, status_code=status_code + ) + + +def _build_callback_redirect( + code: str, state: str, redirect_uri: str, app: AppEntry +) -> RedirectResponse: + """Build the redirect to the callback page or a registered redirect_uri.""" + params = urlencode({"code": code, "state": state}) + if redirect_uri and redirect_uri in app.redirect_uris: + separator = "&" if "?" in redirect_uri else "?" + return RedirectResponse(f"{redirect_uri}{separator}{params}", status_code=302) + return RedirectResponse(f"/auth/device/callback?{params}", status_code=302) + @router.get("/auth/device/login", include_in_schema=False) @limiter.limit(Limits.DEVICE_AUTH) @@ -459,40 +511,113 @@ async def device_login( request: Request, user: OptionalUser, auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), + app_id: str = "", redirect_uri: str = "", state: str = "", -) -> RedirectResponse: - """Initiate the device auth flow. - - If the user already has a valid session, generates an auth code and - redirects to the callback page (or a registered redirect_uri). - Otherwise, redirects to the login page. - Used by browser extensions, mobile apps, and other third-party clients. +) -> Response: + """Initiate the device auth flow with app identification and consent. - The ``state`` parameter is passed through for CSRF protection — the - client generates it, the server carries it, the client verifies it. + Validates the app_id against the registry. If the user has an existing + active grant, auto-approves and generates a code. Otherwise shows the + consent screen. """ - if user: + app = _get_device_app(request, app_id) + if not app: + return _device_error(request, "Unknown or unsupported application") + + if not _validate_redirect_uri(redirect_uri, app): + return _device_error(request, "Invalid redirect URI for this application") + + if not user: + params: dict[str, str] = {"app_id": app_id} + if state: + params["state"] = state + if redirect_uri: + params["redirect_uri"] = redirect_uri + next_url = f"/auth/device/login?{urlencode(params)}" + return RedirectResponse(f"/?next={quote(next_url)}", status_code=302) + + # Check for existing active grant (auto-approve) + grant = await grant_repo.find_active_grant(user.user_id, app_id) + if grant: profile = await auth_service.get_user_profile(str(user.user_id)) - code = await auth_service.create_device_auth_code(profile.id, profile.email) - allowed = request.app.state.settings.device_auth_redirect_uris - validated_uri = _validate_redirect_uri(redirect_uri, allowed) - if validated_uri: - separator = "&" if "?" in validated_uri else "?" - return RedirectResponse( - f"{validated_uri}{separator}code={code}&state={state}", - status_code=302, - ) - return RedirectResponse( - f"/auth/device/callback?code={code}&state={state}", status_code=302 + code = await auth_service.create_device_auth_code( + profile.id, profile.email, app_id=app_id ) + return _build_callback_redirect(code, state, redirect_uri, app) + + # No grant: show consent screen + csrf_token = generate_secure_token(_CSRF_TOKEN_BYTES) + profile = await auth_service.get_user_profile(str(user.user_id)) + response = templates.TemplateResponse( + request, + "device_consent.html", + { + "app": app, + "app_id": app_id, + "state": state, + "redirect_uri": redirect_uri, + "csrf_token": csrf_token, + "user": profile, + }, + ) + response.set_cookie( + _CSRF_COOKIE_NAME, + csrf_token, + httponly=True, + secure=request.app.state.settings.jwt.cookie_secure, + samesite="strict", + max_age=_CSRF_TTL_SECONDS, + ) + return response - # Preserve params through the login flow - params = "state=" + state if state else "" - if redirect_uri: - params += ("&" if params else "") + f"redirect_uri={redirect_uri}" - next_url = "/auth/device/login" + (f"?{params}" if params else "") - return RedirectResponse(f"/?next={next_url}", status_code=302) + +@router.post("/auth/device/consent", include_in_schema=False) +@limiter.limit(Limits.DEVICE_AUTH) +async def device_consent_approve( + request: Request, + user: JwtUser, + auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), + app_id: str = Form(""), + state: str = Form(""), + csrf_token: str = Form(""), + redirect_uri: str = Form(""), +) -> Response: + """Handle consent form submission (Allow button).""" + # CSRF validation + cookie_csrf = request.cookies.get(_CSRF_COOKIE_NAME) + if ( + not cookie_csrf + or not csrf_token + or not secrets.compare_digest(csrf_token, cookie_csrf) + ): + return _device_error( + request, "Invalid or expired consent session. Please try again.", 403 + ) + + app = _get_device_app(request, app_id) + if not app: + return _device_error(request, "Unknown or unsupported application") + + if not _validate_redirect_uri(redirect_uri, app): + return _device_error(request, "Invalid redirect URI for this application") + + # Create grant + await grant_repo.create_or_reactivate(user.user_id, app_id) + log.info("app_consent_granted", user_id=str(user.user_id), app_id=app_id) + + # Generate device auth code + profile = await auth_service.get_user_profile(str(user.user_id)) + code = await auth_service.create_device_auth_code( + profile.id, profile.email, app_id=app_id + ) + + # Clear CSRF cookie and redirect + response = _build_callback_redirect(code, state, redirect_uri, app) + response.delete_cookie(_CSRF_COOKIE_NAME) + return response @router.get("/auth/device/callback", include_in_schema=False) @@ -526,6 +651,7 @@ async def device_token( request: Request, body: DeviceTokenRequest, auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), ) -> DeviceTokenResponse: """Exchange a one-time device auth code for JWT tokens. @@ -536,11 +662,102 @@ async def device_token( **Rate Limits**: 10/min """ - user, access_token, refresh_token = await auth_service.exchange_device_code( + user, access_token, refresh_token, app_id = await auth_service.exchange_device_code( body.code.strip() ) + + # Verify the grant is still active (closes the revoke race window) + if app_id: + grant = await grant_repo.find_active_grant(user.id, app_id) + if not grant: + raise AuthenticationError("app access has been revoked") + try: + await grant_repo.touch_last_used(user.id, app_id) + except Exception: + log.warning("touch_last_used_failed", user_id=str(user.id), app_id=app_id) + return DeviceTokenResponse( access_token=access_token, refresh_token=refresh_token, user=UserProfileResponse.from_user(user), ) + + +# ── App token refresh ───────────────────────────────────────────────────────── + + +@router.post( + "/auth/device/refresh", + responses=ERROR_RESPONSES, + openapi_extra=PUBLIC_SECURITY, + operation_id="refreshDeviceTokens", + summary="Refresh Device Auth Tokens", +) +@limiter.limit(Limits.TOKEN_REFRESH) +async def device_refresh( + request: Request, + body: DeviceRefreshRequest, + auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), +) -> DeviceRefreshResponse: + """Refresh an app's JWT tokens using a refresh token. + + Accepts the refresh token in the request body (not cookies) for use + by external apps (browser extensions, desktop, CLI, bots). If the + refresh token contains an ``app_id`` claim, the server verifies the + app grant is still active — revoked apps cannot refresh. + + **Authentication**: Not required (the refresh token itself is the credential) + + **Rate Limits**: 20/min + """ + user, new_access, new_refresh, app_id = await auth_service.refresh_token( + body.refresh_token + ) + + if app_id: + grant = await grant_repo.find_active_grant(user.id, app_id) + if not grant: + raise AuthenticationError("app access has been revoked") + try: + await grant_repo.touch_last_used(user.id, app_id) + except Exception: + log.warning("touch_last_used_failed", user_id=str(user.id), app_id=app_id) + + return DeviceRefreshResponse( + access_token=new_access, + refresh_token=new_refresh, + ) + + +# ── App revocation (dashboard action) ──────────────────────────────────────── + + +@router.post("/auth/device/revoke", include_in_schema=False) +@limiter.limit(Limits.DEVICE_AUTH) +async def revoke_app( + request: Request, + user: JwtUser, + auth_service: AuthService = Depends(get_auth_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), + app_id: str = Form(""), +) -> Response: + """Revoke an app's access (soft-delete grant + invalidate tokens). + + Protected against CSRF by requiring the X-Requested-With header, + which cannot be sent by cross-origin form submissions. + """ + if request.headers.get(_CSRF_HEADER_NAME) != _CSRF_HEADER_VALUE: + return JSONResponse({"error": "invalid request"}, status_code=403) + + if not app_id or len(app_id) > _APP_ID_MAX_LEN: + return JSONResponse({"error": "app_id is required"}, status_code=400) + + revoked = await grant_repo.revoke(user.user_id, app_id) + if not revoked: + return JSONResponse({"error": "no active grant found"}, status_code=404) + + # Invalidate device auth tokens bound to this app via the public service method + await auth_service.revoke_device_tokens(user.user_id, app_id=app_id) + + return JSONResponse({"success": True, "message": f"Access revoked for {app_id}"}) diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 1fb9f3a8..aae391ba 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -7,6 +7,7 @@ GET /dashboard/statistics → statistics page GET /dashboard/settings → settings page GET /dashboard/billing → billing page +GET /dashboard/apps → connected apps + ecosystem GET /dashboard/profile-pictures → available pictures (JSON) POST /dashboard/profile-pictures → set profile picture (JSON) """ @@ -21,10 +22,13 @@ AuthUser, CurrentUser, OptionalUser, + get_app_grant_repo, get_profile_picture_service, ) from errors import NotFoundError from middleware.rate_limiter import Limits, limiter +from repositories.app_grant_repository import AppGrantRepository +from schemas.models.app import AppEntry, AppStatus from services.profile_picture_service import AvailablePicture, ProfilePictureService from shared.logging import get_logger from shared.templates import templates @@ -123,6 +127,48 @@ async def dashboard_billing( return await _render_dashboard_page("dashboard/billing.html", request, user, svc) +@router.get("/apps") +@limiter.limit(Limits.DASHBOARD_READ) +async def dashboard_apps( + request: Request, + user: OptionalUser, + svc: ProfilePictureService = Depends(get_profile_picture_service), + grant_repo: AppGrantRepository = Depends(get_app_grant_repo), +) -> Response: + if user is None: + return _unauth_redirect() + profile = await svc.get_dashboard_profile(user.user_id) + grants = await grant_repo.find_active_for_user(user.user_id) + app_registry: dict[str, AppEntry] = request.app.state.app_registry + grant_map = {g.app_id: g for g in grants} + + connected: list[dict] = [] + available: list[dict] = [] + coming_soon: list[dict] = [] + + for app_id, app in app_registry.items(): + entry = {"app_id": app_id, **app.model_dump()} + if app_id in grant_map: + entry["grant"] = grant_map[app_id] + connected.append(entry) + elif app.status == AppStatus.COMING_SOON: + coming_soon.append(entry) + else: + available.append(entry) + + return templates.TemplateResponse( + request, + "dashboard/apps.html", + { + "host_url": str(request.base_url), + "user": profile, + "connected": connected, + "available": available, + "coming_soon": coming_soon, + }, + ) + + # ── Profile pictures (JSON API) ───────────────────────────────────────────── diff --git a/schemas/dto/requests/auth.py b/schemas/dto/requests/auth.py index cfe27f8c..69f2851f 100644 --- a/schemas/dto/requests/auth.py +++ b/schemas/dto/requests/auth.py @@ -9,6 +9,7 @@ RequestPasswordResetRequest — POST /auth/request-password-reset ResetPasswordRequest — POST /auth/reset-password DeviceTokenRequest — POST /auth/device/token +DeviceRefreshRequest — POST /auth/device/refresh """ from __future__ import annotations @@ -126,3 +127,14 @@ class DeviceTokenRequest(BaseModel): max_length=128, description="One-time auth code from the device callback page", ) + + +class DeviceRefreshRequest(BaseModel): + """Request body for POST /auth/device/refresh.""" + + model_config = ConfigDict(populate_by_name=True) + + refresh_token: str = Field( + min_length=1, + description="JWT refresh token issued by /auth/device/token", + ) diff --git a/schemas/dto/responses/auth.py b/schemas/dto/responses/auth.py index 2aea472e..68a34829 100644 --- a/schemas/dto/responses/auth.py +++ b/schemas/dto/responses/auth.py @@ -201,6 +201,15 @@ class DeviceTokenResponse(BaseModel): user: UserProfileResponse = Field(description="User profile") +class DeviceRefreshResponse(BaseModel): + """Response body for POST /auth/device/refresh (200).""" + + model_config = ConfigDict(populate_by_name=True) + + access_token: str = Field(description="New JWT access token") + refresh_token: str = Field(description="New JWT refresh token") + + class OAuthProviderDetail(BaseModel): """Detailed OAuth provider entry for the providers list endpoint.""" diff --git a/schemas/models/app.py b/schemas/models/app.py new file mode 100644 index 00000000..2a45438a --- /dev/null +++ b/schemas/models/app.py @@ -0,0 +1,43 @@ +""" +App registry types. + +Defines the Pydantic model and enums for app entries loaded from apps.yaml. +Used for YAML validation at startup and typed access throughout the codebase. +""" + +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, Field + + +class AppStatus(str, Enum): + """App availability status.""" + + LIVE = "live" + COMING_SOON = "coming_soon" + + +class AppType(str, Enum): + """App authentication type.""" + + DEVICE_AUTH = "device_auth" + + +class AppEntry(BaseModel): + """A single app entry from the registry.""" + + name: str = Field(min_length=1, max_length=100) + icon: str | None = None + description: str = Field(min_length=1, max_length=300) + verified: bool = False + status: AppStatus = AppStatus.COMING_SOON + type: AppType = AppType.DEVICE_AUTH + redirect_uris: list[str] = [] + links: dict[str, str] = {} + permissions: list[str] = [] + + def is_live_device_app(self) -> bool: + """Check if this app can participate in the device auth consent flow.""" + return self.status == AppStatus.LIVE and self.type == AppType.DEVICE_AUTH diff --git a/schemas/models/app_grant.py b/schemas/models/app_grant.py new file mode 100644 index 00000000..9c24d3fe --- /dev/null +++ b/schemas/models/app_grant.py @@ -0,0 +1,24 @@ +""" +App grant document model. + +Maps to the `app-grants` MongoDB collection. + +Tracks which apps a user has authorized via the consent flow. +Soft-deleted on revoke (revoked_at is set instead of deleting the document). +""" + +from __future__ import annotations + +from datetime import datetime + +from schemas.models.base import MongoBaseModel, PyObjectId + + +class AppGrantDoc(MongoBaseModel): + """Document model for the `app-grants` collection.""" + + user_id: PyObjectId + app_id: str # matches key in config/apps.yaml + granted_at: datetime + last_used_at: datetime | None = None + revoked_at: datetime | None = None # soft delete diff --git a/schemas/models/token.py b/schemas/models/token.py index dc9d3223..5f7d8e26 100644 --- a/schemas/models/token.py +++ b/schemas/models/token.py @@ -3,7 +3,7 @@ Maps to the `verification-tokens` MongoDB collection. -Used for both email verification OTPs and password reset OTPs. +Used for email verification OTPs, password reset OTPs, and device auth codes. token_hash stores SHA-256(otp_code) — the plain OTP is never stored. used_at is None until the token is consumed. attempts tracks failed verification tries (max 5 before the token is dead). @@ -44,3 +44,4 @@ class VerificationTokenDoc(MongoBaseModel): created_at: datetime | None = None used_at: datetime | None = None attempts: int = Field(default=0, ge=0) + app_id: str | None = None # set for device_auth tokens (consent flow) diff --git a/services/auth_service.py b/services/auth_service.py index f07a3982..edde2dd5 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -334,14 +334,17 @@ async def register( return user_doc, access_token, refresh_token, verification_sent - async def refresh_token(self, refresh_token_str: str) -> tuple[UserDoc, str, str]: + async def refresh_token( + self, refresh_token_str: str + ) -> tuple[UserDoc, str, str, str | None]: """Perform stateless token rotation. Verifies the refresh JWT, re-fetches the user to get the latest - ``email_verified`` status, and issues a new token pair. + ``email_verified`` status, and issues a new token pair. The original + ``amr`` and ``app_id`` claims are preserved across rotations. Returns: - (user_doc, new_access_token, new_refresh_token) + (user_doc, new_access_token, new_refresh_token, app_id) Raises: AuthenticationError: Token invalid/expired, or user not found/inactive. @@ -361,9 +364,12 @@ async def refresh_token(self, refresh_token_str: str) -> tuple[UserDoc, str, str ) raise AuthenticationError("invalid or expired refresh token") - log.info("token_refreshed", user_id=user_id) - new_access, new_refresh = self._tokens.issue_tokens(user, "pwd") - return user, new_access, new_refresh + amr = claims.get("amr", ["pwd"])[0] + app_id = claims.get("app_id") + + log.info("token_refreshed", user_id=user_id, amr=amr, app_id=app_id) + new_access, new_refresh = self._tokens.issue_tokens(user, amr, app_id=app_id) + return user, new_access, new_refresh, app_id async def verify_email(self, user_id: str, otp_code: str) -> tuple[str, str]: """Verify a user's email address using an OTP code. @@ -593,35 +599,42 @@ async def get_user_profile(self, user_id: str) -> UserDoc: # ── Device auth flow ───────────────────────────────────────────────────── - async def create_device_auth_code(self, user_id: ObjectId, email: str) -> str: + async def create_device_auth_code( + self, user_id: ObjectId, email: str, app_id: str | None = None + ) -> str: """Generate a one-time auth code for the device auth flow. Returns the raw token (caller redirects to callback with it). """ - await self._token_repo.delete_by_user(user_id, TOKEN_TYPE_DEVICE_AUTH) + await self._token_repo.delete_by_user( + user_id, TOKEN_TYPE_DEVICE_AUTH, app_id=app_id + ) raw_token = generate_secure_token(48) now = datetime.now(timezone.utc) - await self._token_repo.create( - { - "user_id": user_id, - "email": email, - "token_hash": hash_token(raw_token), - "token_type": TOKEN_TYPE_DEVICE_AUTH, - "expires_at": now + timedelta(seconds=DEVICE_AUTH_EXPIRY_SECONDS), - "created_at": now, - "used_at": None, - "attempts": 0, - } - ) - log.info("device_auth_code_created", user_id=str(user_id)) + token_data: dict = { + "user_id": user_id, + "email": email, + "token_hash": hash_token(raw_token), + "token_type": TOKEN_TYPE_DEVICE_AUTH, + "expires_at": now + timedelta(seconds=DEVICE_AUTH_EXPIRY_SECONDS), + "created_at": now, + "used_at": None, + "attempts": 0, + } + if app_id: + token_data["app_id"] = app_id + await self._token_repo.create(token_data) + log.info("device_auth_code_created", user_id=str(user_id), app_id=app_id) return raw_token - async def exchange_device_code(self, code: str) -> tuple[UserDoc, str, str]: + async def exchange_device_code( + self, code: str + ) -> tuple[UserDoc, str, str, str | None]: """Exchange a one-time device auth code for JWT tokens. Returns: - (user_doc, access_token, refresh_token) + (user_doc, access_token, refresh_token, app_id) Raises: AuthenticationError: Code invalid, expired, or already used. @@ -637,6 +650,27 @@ async def exchange_device_code(self, code: str) -> tuple[UserDoc, str, str]: if not user or user.status != UserStatus.ACTIVE: raise AuthenticationError("user not found or inactive") - log.info("device_auth_success", user_id=str(user.id)) - access_token, refresh_token = self._tokens.issue_tokens(user, "ext") - return user, access_token, refresh_token + app_id = token_doc.app_id + log.info("device_auth_success", user_id=str(user.id), app_id=app_id) + access_token, refresh_token = self._tokens.issue_tokens( + user, "ext", app_id=app_id + ) + return user, access_token, refresh_token, app_id + + async def revoke_device_tokens( + self, user_id: ObjectId, app_id: str | None = None + ) -> int: + """Invalidate device auth tokens for a user, optionally filtered by app_id. + + Returns the number of tokens deleted. + """ + count = await self._token_repo.delete_by_user( + user_id, TOKEN_TYPE_DEVICE_AUTH, app_id=app_id + ) + log.info( + "device_tokens_revoked", + user_id=str(user_id), + app_id=app_id, + count=count, + ) + return count diff --git a/services/token_factory.py b/services/token_factory.py index 82d3901f..977fcdc2 100644 --- a/services/token_factory.py +++ b/services/token_factory.py @@ -72,11 +72,16 @@ def generate_access_token(self, user: UserDoc, *, amr: str) -> str: } return pyjwt.encode(payload, self._signing_key(), algorithm=self._algorithm()) - def generate_refresh_token(self, user: UserDoc, *, amr: str) -> str: + def generate_refresh_token( + self, user: UserDoc, *, amr: str, app_id: str | None = None + ) -> str: """Issue a signed JWT refresh token for *user*. Identical to the access token but includes ``"type": "refresh"`` and uses ``refresh_token_ttl_seconds`` for the expiry window. + + When *app_id* is provided (device auth flow), it is embedded as a claim + so the refresh endpoint can enforce grant checks on a per-app basis. """ now = int(datetime.now(timezone.utc).timestamp()) payload: dict[str, Any] = { @@ -89,9 +94,13 @@ def generate_refresh_token(self, user: UserDoc, *, amr: str) -> str: "email_verified": user.email_verified, "type": "refresh", } + if app_id: + payload["app_id"] = app_id return pyjwt.encode(payload, self._signing_key(), algorithm=self._algorithm()) - def issue_tokens(self, user: UserDoc, amr: str) -> tuple[str, str]: + def issue_tokens( + self, user: UserDoc, amr: str, *, app_id: str | None = None + ) -> tuple[str, str]: """Issue an access + refresh token pair for *user*. Returns: @@ -99,7 +108,7 @@ def issue_tokens(self, user: UserDoc, amr: str) -> tuple[str, str]: """ return ( self.generate_access_token(user, amr=amr), - self.generate_refresh_token(user, amr=amr), + self.generate_refresh_token(user, amr=amr, app_id=app_id), ) # ── Token verification ──────────────────────────────────────────────────── diff --git a/shared/app_registry.py b/shared/app_registry.py new file mode 100644 index 00000000..391dd7af --- /dev/null +++ b/shared/app_registry.py @@ -0,0 +1,78 @@ +""" +App registry loader. + +Reads apps.yaml at startup, validates each entry with Pydantic, +and returns a typed dict of AppEntry models. +""" + +from __future__ import annotations + +from pathlib import Path + +import yaml +from pydantic import ValidationError + +from schemas.models.app import AppEntry, AppStatus +from shared.logging import get_logger + +log = get_logger(__name__) + +_PROJECT_ROOT = Path(__file__).parent.parent +_APPS_YAML = _PROJECT_ROOT / "config" / "apps.yaml" +_STATIC_APPS_DIR = _PROJECT_ROOT / "static" / "images" / "apps" + + +def load_app_registry(config_path: Path | None = None) -> dict[str, AppEntry]: + """Load and validate the app registry from YAML. + + Returns a dict mapping app_id -> validated AppEntry model. + Invalid entries are skipped with a warning. + """ + path = config_path or _APPS_YAML + if not path.exists(): + log.warning("app_registry_not_found", path=str(path)) + return {} + + try: + with open(path) as f: + data = yaml.safe_load(f) + except (OSError, yaml.YAMLError) as exc: + log.error("app_registry_parse_error", path=str(path), error=str(exc)) + return {} + + if not data or not isinstance(data, dict): + log.warning("app_registry_invalid_format", path=str(path)) + return {} + + raw_apps = data.get("apps", {}) + if not raw_apps: + log.warning("app_registry_empty", path=str(path)) + return {} + + registry: dict[str, AppEntry] = {} + for app_id, app_data in raw_apps.items(): + try: + entry = AppEntry.model_validate(app_data) + except ValidationError as exc: + log.warning( + "app_registry_entry_invalid", + app_id=app_id, + error=str(exc), + ) + continue + + # Validate icon files exist for live apps + if entry.status == AppStatus.LIVE and entry.icon: + icon_path = _STATIC_APPS_DIR / entry.icon + if not icon_path.exists(): + log.warning( + "app_icon_missing", + app_id=app_id, + icon=entry.icon, + expected_path=str(icon_path), + ) + + registry[app_id] = entry + + log.info("app_registry_loaded", app_count=len(registry)) + return registry diff --git a/static/css/dashboard/apps.css b/static/css/dashboard/apps.css new file mode 100644 index 00000000..bc56f714 --- /dev/null +++ b/static/css/dashboard/apps.css @@ -0,0 +1,414 @@ +/* Apps Page Styles */ + +.apps-section { + margin-bottom: 2rem; +} + +.section-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary, #999); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.75rem; + padding-left: 2px; +} + +.apps-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +/* App Card */ +.app-card { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02)); + border: 1px solid var(--border-color, #262626); + border-radius: var(--radius-md, 8px); + padding: 1rem; + transition: border-color 0.15s; +} + +.app-card:hover { + border-color: rgba(255, 255, 255, 0.15); +} + +/* Clickable card (available apps) */ +.app-card--link { + text-decoration: none; + color: inherit; + position: relative; + cursor: pointer; +} + +.app-card-arrow { + position: absolute; + top: 0.75rem; + right: 0.75rem; + font-size: 1.1rem; + color: var(--text-secondary, #999); + opacity: 0; + transition: opacity 0.15s, transform 0.15s, filter 0.15s; + transform: translate(-2px, 2px); +} + +.app-card--link:hover .app-card-arrow { + opacity: 1; + transform: translate(0, 0); + filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.3)); +} + +.app-card--link .app-card-header, +.app-card--muted .app-card-header { + margin-bottom: 0; +} + +/* Connected status dot */ +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #22c55e; + flex-shrink: 0; + /* box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); */ +} + +/* Card footer (last active) */ +.app-card-footer { + padding-top: 0.3rem; + margin-top: 0.1rem; +} + +/* Kebab menu */ +.kebab-menu { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + +.app-card { + position: relative; +} + +.kebab-btn { + background: transparent; + border: none; + color: var(--text-secondary, #666); + cursor: pointer; + padding: 0.25rem; + border-radius: 4px; + font-size: 1.1rem; + line-height: 1; + transition: color 0.15s, background 0.15s, opacity 0.15s; + opacity: 0; +} + +.app-card:hover .kebab-btn, +.kebab-menu:has(.kebab-dropdown.open) .kebab-btn, +.kebab-btn:focus { + opacity: 1; +} + +.kebab-btn:hover { + color: var(--text-primary, #e5e5e5); + background: rgba(255, 255, 255, 0.08); +} + +.kebab-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: rgba(12, 14, 24, 0.98); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md, 8px); + padding: 4px; + min-width: 150px; + z-index: 100; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 0, 0, 0.2); + opacity: 0; + visibility: hidden; + transform: translateY(6px); + transition: all 0.2s; +} + +.kebab-dropdown.open { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.kebab-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: none; + background: transparent; + color: var(--text-secondary, #999); + font-size: 14px; + cursor: pointer; + border-radius: var(--radius-sm, 6px); + transition: all 0.15s; +} + +.kebab-item i { + width: 18px; + height: 18px; + flex-shrink: 0; + font-size: 18px; +} + +.kebab-item:hover { + background: var(--hover-bg, rgba(255, 255, 255, 0.06)); + color: var(--text-primary, #e5e5e5); +} + +.kebab-item--danger:hover { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.app-card--muted { + opacity: 0.7; +} + +.app-card--muted:hover { + opacity: 0.85; + border-color: var(--border-color, #262626); +} + +/* Card Header */ +.app-card-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.app-icon { + width: 40px; + height: 40px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; + padding: 2px; +} + +.app-icon img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 6px; +} + +.app-icon i { + font-size: 1.25rem; + color: var(--text-secondary, #999); +} + +.app-icon--muted { + filter: grayscale(0.1); +} + +.app-info { + min-width: 0; + flex: 1; +} + +.app-name-row { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.15rem; +} + +.app-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary, #e5e5e5); +} + +.verified-badge { + color: #3b82f6; + font-size: 0.9rem; +} + +.app-description { + font-size: 0.775rem; + color: var(--text-secondary, #999); + display: block; +} + +.badge { + font-size: 0.65rem; + font-weight: 500; + padding: 0.15rem 0.4rem; + border-radius: 4px; + white-space: nowrap; +} + +.badge-muted { + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary, #999); +} + +/* Card Meta */ +.app-card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.meta-item { + font-size: 0.7rem; + color: var(--text-secondary, #666); + display: flex; + align-items: center; + gap: 0.3rem; +} + +.meta-item i { + font-size: 0.8rem; +} + +/* Card Actions */ +.app-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.75rem; + border-radius: var(--radius-sm, 6px); + font-size: 0.775rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + border: none; + text-decoration: none; +} + +.btn-sm { + padding: 0.35rem 0.625rem; + font-size: 0.725rem; +} + +.btn-outline { + background: transparent; + border: 1px solid var(--border-color, #333); + color: var(--text-primary, #e5e5e5); +} + +.btn-outline:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.2); +} + +.btn-danger-outline { + background: transparent; + border: 1px solid rgba(239, 68, 68, 0.3); + color: #ef4444; +} + +.btn-danger-outline:hover { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.5); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary, #999); +} + +.btn-ghost:hover { + color: var(--text-primary, #e5e5e5); + background: rgba(255, 255, 255, 0.06); +} + +.btn-danger { + background: #ef4444; + color: #fff; +} + +.btn-danger:hover { + background: #dc2626; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 2.5rem 1rem; + color: var(--text-secondary, #666); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent); + border: 1px dashed var(--border-color, #262626); + border-radius: var(--radius-md, 8px); +} + +.empty-state i { + font-size: 2rem; + display: block; + margin-bottom: 0.5rem; + opacity: 0.5; +} + +.empty-state p { + font-size: 0.8rem; +} + +/* Disconnect modal overrides */ +#revoke-dialog .modal-icon { + width: 36px; + height: 36px; + border-radius: 8px; + font-size: 18px; +} + +#revoke-dialog .modal-title { + font-size: 18px; +} + +#revoke-dialog .modal-subtitle { + font-size: 13px; +} + +#revoke-dialog .modal-body { + padding: 20px 24px; +} + +#revoke-dialog .warning-content p { + font-size: 14px; + text-align: left; +} + +#revoke-dialog .modal-header { + padding: 16px 20px; +} + +#revoke-dialog .modal-footer { + padding: 16px 20px; +} + +/* Responsive */ +@media (max-width: 1024px) { + .apps-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .apps-grid { + grid-template-columns: 1fr; + } +} diff --git a/static/images/apps/discord-bot.svg b/static/images/apps/discord-bot.svg new file mode 100644 index 00000000..c03e8e12 --- /dev/null +++ b/static/images/apps/discord-bot.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/static/images/apps/mcp.svg b/static/images/apps/mcp.svg new file mode 100644 index 00000000..5cd83a8b --- /dev/null +++ b/static/images/apps/mcp.svg @@ -0,0 +1 @@ +ModelContextProtocol \ No newline at end of file diff --git a/static/images/apps/n8n.svg b/static/images/apps/n8n.svg new file mode 100644 index 00000000..82f0a6da --- /dev/null +++ b/static/images/apps/n8n.svg @@ -0,0 +1 @@ +n8n \ No newline at end of file diff --git a/static/images/apps/raycast.svg b/static/images/apps/raycast.svg new file mode 100644 index 00000000..2ce034a0 --- /dev/null +++ b/static/images/apps/raycast.svg @@ -0,0 +1 @@ +Raycast \ No newline at end of file diff --git a/static/images/apps/slack-bot.svg b/static/images/apps/slack-bot.svg new file mode 100644 index 00000000..ff256fd4 --- /dev/null +++ b/static/images/apps/slack-bot.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/static/images/apps/spoo-cli.svg b/static/images/apps/spoo-cli.svg new file mode 100644 index 00000000..a30e72c1 --- /dev/null +++ b/static/images/apps/spoo-cli.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/static/images/apps/spoo-desktop.svg b/static/images/apps/spoo-desktop.svg new file mode 100644 index 00000000..4fbbd4e7 --- /dev/null +++ b/static/images/apps/spoo-desktop.svg @@ -0,0 +1 @@ + diff --git a/static/images/apps/spoo-mobile.svg b/static/images/apps/spoo-mobile.svg new file mode 100644 index 00000000..e0d53431 --- /dev/null +++ b/static/images/apps/spoo-mobile.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/static/images/apps/spoo-snap.svg b/static/images/apps/spoo-snap.svg new file mode 100644 index 00000000..c24e7087 --- /dev/null +++ b/static/images/apps/spoo-snap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/apps/telegram-bot.svg b/static/images/apps/telegram-bot.svg new file mode 100644 index 00000000..63f03989 --- /dev/null +++ b/static/images/apps/telegram-bot.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/static/images/apps/vscode.svg b/static/images/apps/vscode.svg new file mode 100644 index 00000000..c453e633 --- /dev/null +++ b/static/images/apps/vscode.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/apps/zapier.svg b/static/images/apps/zapier.svg new file mode 100644 index 00000000..6ee0b5ec --- /dev/null +++ b/static/images/apps/zapier.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/templates/dashboard/apps.html b/templates/dashboard/apps.html new file mode 100644 index 00000000..383ec22a --- /dev/null +++ b/templates/dashboard/apps.html @@ -0,0 +1,244 @@ +{% extends "dashboard/base.html" %} + +{% block title %}Apps | Dashboard | spoo.me{% endblock %} + +{% block head_css %} + + + +{% endblock %} + +{% block content %} + + + +
+

Connected

+ {% if connected %} +
+ {% for app in connected %} +
+
+
+ {% if app.icon %} + {{ app.name }} + {% else %} + + {% endif %} +
+
+
+ {{ app.name }} + {% if app.verified %} + + {% endif %} + +
+ {{ app.description }} +
+
+ +
+ +
+
+
+ +
+ {% endfor %} +
+ {% else %} +
+ +

No apps connected yet. Install an app below to get started.

+
+ {% endif %} +
+ + +{% if available %} +
+

Available

+
+ {% for app in available %} + +
+
+ {% if app.icon %} + {{ app.name }} + {% else %} + + {% endif %} +
+
+
+ {{ app.name }} + {% if app.verified %} + + {% endif %} +
+ {{ app.description }} +
+
+ +
+ {% endfor %} +
+
+{% endif %} + + +{% if coming_soon %} +
+

Coming Soon

+
+ {% for app in coming_soon %} +
+
+
+ {% if app.icon %} + {{ app.name }} + {% else %} + + {% endif %} +
+
+
+ {{ app.name }} + Coming Soon +
+ {{ app.description }} +
+
+
+ {% endfor %} +
+
+{% endif %} + + + + + +{% endblock %} diff --git a/templates/dashboard/partials/sidebar.html b/templates/dashboard/partials/sidebar.html index c69b2c9b..fcde20d7 100644 --- a/templates/dashboard/partials/sidebar.html +++ b/templates/dashboard/partials/sidebar.html @@ -31,6 +31,12 @@ Statistics + + + Apps + + diff --git a/templates/device_consent.html b/templates/device_consent.html new file mode 100644 index 00000000..1d146807 --- /dev/null +++ b/templates/device_consent.html @@ -0,0 +1,251 @@ + + + + + + spoo.me - Authorize App + + + +
+ +
+
+ {% if user.pfp and user.pfp.url %} + {{ user.user_name or user.email }} + {% else %} + {{ (user.user_name or user.email)[:2]|upper }} + {% endif %} +
+ +
+ + +
+
+ {% if app.icon %} + {{ app.name }} + {% else %} + + {% endif %} +
+
+ {{ app.name }} + {% if app.verified %} + + + + {% endif %} +
+
{{ app.description }}
+
+ + + {% if app.permissions %} +
+
This app will be able to
+
    + {% for perm in app.permissions %} +
  • + + {{ perm }} +
  • + {% endfor %} +
+
+ {% endif %} + + +
+ + + + + + Deny +
+ + + +
+ + diff --git a/templates/device_error.html b/templates/device_error.html new file mode 100644 index 00000000..f61a481d --- /dev/null +++ b/templates/device_error.html @@ -0,0 +1,42 @@ + + + + + + spoo.me - Error + + + +
+
+

Authorization Error

+

{{ error }}

+ Back to spoo.me +
+ + diff --git a/tests/integration/test_auth_flow.py b/tests/integration/test_auth_flow.py index 27316892..54827c3e 100644 --- a/tests/integration/test_auth_flow.py +++ b/tests/integration/test_auth_flow.py @@ -181,7 +181,7 @@ def test_login_then_refresh_rotates_tokens(): mock_svc = AsyncMock() mock_svc.login.return_value = (user, _ACCESS_TOKEN, _REFRESH_TOKEN) - mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh") + mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh", None) app = _build_test_app({get_auth_service: lambda: mock_svc}) with TestClient(app, raise_server_exceptions=False) as client: @@ -344,6 +344,7 @@ def test_full_auth_journey(): verified_user, "refreshed.access", "refreshed.refresh", + None, ) mock_svc.get_user_profile.return_value = verified_user mock_svc.set_password.side_effect = ValidationError("password already set") diff --git a/tests/integration/test_auth_routes.py b/tests/integration/test_auth_routes.py index 69365faa..2a95c5a2 100644 --- a/tests/integration/test_auth_routes.py +++ b/tests/integration/test_auth_routes.py @@ -208,7 +208,7 @@ def test_register_weak_password_returns_400(): def test_refresh_rotates_tokens(): user = _make_user_doc() mock_svc = AsyncMock() - mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh") + mock_svc.refresh_token.return_value = (user, "new.access", "new.refresh", None) app = _build_test_app({get_auth_service: lambda: mock_svc}) with TestClient(app, raise_server_exceptions=False) as client: diff --git a/tests/integration/test_device_auth.py b/tests/integration/test_device_auth.py index a2a3ad9e..b4b66cd5 100644 --- a/tests/integration/test_device_auth.py +++ b/tests/integration/test_device_auth.py @@ -7,8 +7,10 @@ from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock +import pytest from bson import ObjectId from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles from fastapi.testclient import TestClient from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded @@ -16,37 +18,45 @@ os.environ.setdefault("MONGODB_URI", "mongodb://localhost:27017/") from config import AppSettings -from dependencies import get_auth_service, get_current_user +from dependencies import get_app_grant_repo, get_auth_service, get_current_user +from dependencies.auth import CurrentUser from errors import AuthenticationError from middleware.error_handler import register_error_handlers from middleware.rate_limiter import limiter from routes.auth_routes import router as auth_router +from schemas.models.app import AppEntry, AppStatus, AppType +from schemas.models.app_grant import AppGrantDoc from schemas.models.user import UserDoc _USER_OID = ObjectId() _EMAIL = "test@example.com" - - -def _build_test_app(overrides: dict) -> FastAPI: - settings = AppSettings() - - @asynccontextmanager - async def lifespan(app: FastAPI): - app.state.settings = settings - app.state.db = MagicMock() - app.state.redis = None - app.state.email_provider = MagicMock() - app.state.http_client = MagicMock() - app.state.oauth_providers = {} - yield - - app = FastAPI(lifespan=lifespan) - app.state.limiter = limiter - app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - register_error_handlers(app) - app.include_router(auth_router) - app.dependency_overrides.update(overrides) - return app +_PROJECT_ROOT = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) + +_TEST_APP_REGISTRY: dict[str, AppEntry] = { + "spoo-snap": AppEntry( + name="Spoo Snap", + icon="spoo-snap.svg", + description="Official browser extension", + verified=True, + status=AppStatus.LIVE, + type=AppType.DEVICE_AUTH, + redirect_uris=[], + permissions=["Access your account"], + ), + "spoo-future": AppEntry( + name="Future App", + icon="future.svg", + description="Not yet released", + verified=True, + status=AppStatus.COMING_SOON, + type=AppType.DEVICE_AUTH, + ), +} + + +# ── Fixtures ────────────────────────────────────────────────────────────────── def _make_user_doc() -> UserDoc: @@ -65,34 +75,147 @@ def _make_user_doc() -> UserDoc: ) -# ── GET /auth/device/login ─────────────────────────────────────────────────── +def _make_grant(app_id: str = "spoo-snap") -> AppGrantDoc: + return AppGrantDoc.from_mongo( + { + "_id": ObjectId(), + "user_id": _USER_OID, + "app_id": app_id, + "granted_at": datetime.now(timezone.utc), + "last_used_at": None, + "revoked_at": None, + } + ) + + +@pytest.fixture() +def auth_svc(): + svc = AsyncMock() + svc.get_user_profile.return_value = _make_user_doc() + return svc + + +@pytest.fixture() +def grant_repo(): + repo = AsyncMock() + repo.find_active_grant.return_value = None + repo.create_or_reactivate.return_value = MagicMock() + repo.touch_last_used.return_value = None + return repo + + +@pytest.fixture() +def anon_user(): + return None + + +@pytest.fixture() +def authed_user(): + return CurrentUser(user_id=_USER_OID, email_verified=True) + + +@pytest.fixture() +def _app_factory(): + """Returns a factory that builds a TestClient with given mocks.""" + _clients: list[TestClient] = [] + + def _make(auth_svc, grant_repo, user=None) -> TestClient: + settings = AppSettings() + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.settings = settings + app.state.db = MagicMock() + app.state.redis = None + app.state.email_provider = MagicMock() + app.state.http_client = MagicMock() + app.state.oauth_providers = {} + app.state.app_registry = _TEST_APP_REGISTRY + yield + + app = FastAPI(lifespan=lifespan) + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + register_error_handlers(app) + + static_dir = os.path.join(_PROJECT_ROOT, "static") + if os.path.isdir(static_dir): + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + app.include_router(auth_router) + app.dependency_overrides[get_auth_service] = lambda: auth_svc + app.dependency_overrides[get_app_grant_repo] = lambda: grant_repo + app.dependency_overrides[get_current_user] = ( + (lambda: user) if user is not None else (lambda: None) + ) + client = TestClient(app, raise_server_exceptions=False) + client.__enter__() + _clients.append(client) + return client -def test_device_login_unauthenticated_redirects_to_index(): - mock_svc = AsyncMock() - app = _build_test_app( - {get_auth_service: lambda: mock_svc, get_current_user: lambda: None} + yield _make + + for c in _clients: + c.__exit__(None, None, None) + + +# ── GET /auth/device/login — validation ────────────────────────────────────── + + +@pytest.mark.parametrize( + "url", + [ + "/auth/device/login?state=abc", # missing app_id + "/auth/device/login?app_id=unknown-app&state=abc", # unknown app + "/auth/device/login?app_id=spoo-future&state=abc", # coming_soon + ], + ids=["missing_app_id", "unknown_app_id", "coming_soon_app"], +) +def test_device_login_invalid_app_returns_400(auth_svc, grant_repo, url, _app_factory): + c = _app_factory(auth_svc, grant_repo) + resp = c.get(url) + assert resp.status_code == 400 + assert "Unknown or unsupported" in resp.text + + +def test_device_login_invalid_redirect_uri(auth_svc, grant_repo, _app_factory): + c = _app_factory(auth_svc, grant_repo) + resp = c.get( + "/auth/device/login?app_id=spoo-snap&state=abc&redirect_uri=https://evil.com" + ) + assert resp.status_code == 400 + assert "Invalid redirect URI" in resp.text + + +# ── GET /auth/device/login — unauthenticated ───────────────────────────────── + + +def test_device_login_unauthenticated_redirects(auth_svc, grant_repo, _app_factory): + c = _app_factory(auth_svc, grant_repo) + resp = c.get( + "/auth/device/login?app_id=spoo-snap&state=abc", follow_redirects=False ) - with TestClient(app, raise_server_exceptions=False) as c: - resp = c.get("/auth/device/login?state=abc", follow_redirects=False) assert resp.status_code == 302 - assert "/?next=" in resp.headers["location"] - assert "state=abc" in resp.headers["location"] + loc = resp.headers["location"] + assert "/?next=" in loc + assert "spoo-snap" in loc + assert "abc" in loc + +# ── GET /auth/device/login — authenticated ─────────────────────────────────── -def test_device_login_authenticated_redirects_to_callback(): - from dependencies.auth import CurrentUser - mock_svc = AsyncMock() - mock_svc.get_user_profile.return_value = _make_user_doc() - mock_svc.create_device_auth_code.return_value = "test-code-123" +def test_device_login_with_grant_auto_approves( + auth_svc, grant_repo, authed_user, _app_factory +): + auth_svc.create_device_auth_code.return_value = "test-code-123" + grant_repo.find_active_grant.return_value = _make_grant() - user = CurrentUser(user_id=_USER_OID, email_verified=True) - app = _build_test_app( - {get_auth_service: lambda: mock_svc, get_current_user: lambda: user} + c = _app_factory(auth_svc, grant_repo, authed_user) + resp = c.get( + "/auth/device/login?app_id=spoo-snap&state=xyz", follow_redirects=False ) - with TestClient(app, raise_server_exceptions=False) as c: - resp = c.get("/auth/device/login?state=xyz", follow_redirects=False) assert resp.status_code == 302 loc = resp.headers["location"] assert "/auth/device/callback" in loc @@ -100,53 +223,191 @@ def test_device_login_authenticated_redirects_to_callback(): assert "state=xyz" in loc +def test_device_login_without_grant_shows_consent( + auth_svc, grant_repo, authed_user, _app_factory +): + c = _app_factory(auth_svc, grant_repo, authed_user) + resp = c.get("/auth/device/login?app_id=spoo-snap&state=xyz") + assert resp.status_code == 200 + assert "Spoo Snap" in resp.text + assert "Allow" in resp.text + assert "Connecting as" in resp.text + assert "csrf_token" in resp.text + + # ── GET /auth/device/callback ──────────────────────────────────────────────── -def test_device_callback_no_code_redirects_home(): - mock_svc = AsyncMock() - app = _build_test_app({get_auth_service: lambda: mock_svc}) - with TestClient(app, raise_server_exceptions=False) as c: - resp = c.get("/auth/device/callback", follow_redirects=False) +def test_device_callback_no_code_redirects(auth_svc, grant_repo, _app_factory): + c = _app_factory(auth_svc, grant_repo) + resp = c.get("/auth/device/callback", follow_redirects=False) assert resp.status_code == 302 assert resp.headers["location"] == "/" -def test_device_callback_with_code_renders_page(): - mock_svc = AsyncMock() - app = _build_test_app({get_auth_service: lambda: mock_svc}) - with TestClient(app, raise_server_exceptions=False) as c: - resp = c.get("/auth/device/callback?code=abc&state=xyz") +def test_device_callback_with_code_renders(auth_svc, grant_repo, _app_factory): + c = _app_factory(auth_svc, grant_repo) + resp = c.get("/auth/device/callback?code=abc&state=xyz") assert resp.status_code == 200 assert 'data-code="abc"' in resp.text assert 'data-state="xyz"' in resp.text -# ── POST /auth/device/token ───────────────────────────────────────────────── +# ── POST /auth/device/token ────────────────────────────────────────────────── -def test_device_token_valid_code(): - mock_svc = AsyncMock() +def test_device_token_valid_code(auth_svc, grant_repo, _app_factory): user = _make_user_doc() - mock_svc.exchange_device_code.return_value = (user, "access-tok", "refresh-tok") + auth_svc.exchange_device_code.return_value = (user, "at", "rt", "spoo-snap") + grant_repo.find_active_grant.return_value = _make_grant() - app = _build_test_app({get_auth_service: lambda: mock_svc}) - with TestClient(app, raise_server_exceptions=False) as c: - resp = c.post("/auth/device/token", json={"code": "valid-code"}) + c = _app_factory(auth_svc, grant_repo) + resp = c.post("/auth/device/token", json={"code": "valid-code"}) assert resp.status_code == 200 data = resp.json() - assert data["access_token"] == "access-tok" - assert data["refresh_token"] == "refresh-tok" + assert data["access_token"] == "at" + assert data["refresh_token"] == "rt" assert data["user"]["email"] == _EMAIL + grant_repo.touch_last_used.assert_awaited_once() -def test_device_token_invalid_code(): - mock_svc = AsyncMock() - mock_svc.exchange_device_code.side_effect = AuthenticationError( - "invalid or expired device auth code" +def test_device_token_invalid_code(auth_svc, grant_repo, _app_factory): + auth_svc.exchange_device_code.side_effect = AuthenticationError( + "invalid or expired" ) - app = _build_test_app({get_auth_service: lambda: mock_svc}) - with TestClient(app, raise_server_exceptions=False) as c: - resp = c.post("/auth/device/token", json={"code": "bad-code"}) + c = _app_factory(auth_svc, grant_repo) + resp = c.post("/auth/device/token", json={"code": "bad"}) + assert resp.status_code == 401 + + +def test_device_token_revoked_grant_rejected(auth_svc, grant_repo, _app_factory): + """Token exchange fails if grant was revoked between consent and exchange.""" + user = _make_user_doc() + auth_svc.exchange_device_code.return_value = (user, "at", "rt", "spoo-snap") + grant_repo.find_active_grant.return_value = None # revoked + + c = _app_factory(auth_svc, grant_repo) + resp = c.post("/auth/device/token", json={"code": "valid"}) + assert resp.status_code == 401 + assert "revoked" in resp.json()["error"].lower() + + +# ── POST /auth/device/consent ──────────────────────────────────────────────── + + +def test_consent_missing_csrf_rejected(auth_svc, grant_repo, authed_user, _app_factory): + c = _app_factory(auth_svc, grant_repo, authed_user) + resp = c.post( + "/auth/device/consent", + data={"app_id": "spoo-snap", "state": "xyz", "csrf_token": "wrong"}, + ) + assert resp.status_code == 403 + assert "Invalid or expired" in resp.text + + +def test_consent_valid_creates_grant(auth_svc, grant_repo, authed_user, _app_factory): + auth_svc.create_device_auth_code.return_value = "consent-code-123" + + c = _app_factory(auth_svc, grant_repo, authed_user) + c.cookies.set("_consent_csrf", "valid-tok") + resp = c.post( + "/auth/device/consent", + data={ + "app_id": "spoo-snap", + "state": "xyz", + "csrf_token": "valid-tok", + "redirect_uri": "", + }, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert "consent-code-123" in resp.headers["location"] + grant_repo.create_or_reactivate.assert_awaited_once() + + +def test_consent_unknown_app_rejected(auth_svc, grant_repo, authed_user, _app_factory): + c = _app_factory(auth_svc, grant_repo, authed_user) + c.cookies.set("_consent_csrf", "tok") + resp = c.post( + "/auth/device/consent", + data={"app_id": "unknown", "state": "", "csrf_token": "tok"}, + ) + assert resp.status_code == 400 + + +# ── POST /auth/device/revoke ───────────────────────────────────────────────── + + +def test_revoke_without_csrf_header_rejected( + auth_svc, grant_repo, authed_user, _app_factory +): + c = _app_factory(auth_svc, grant_repo, authed_user) + resp = c.post("/auth/device/revoke", data={"app_id": "spoo-snap"}) + assert resp.status_code == 403 + + +def test_revoke_success(auth_svc, grant_repo, authed_user, _app_factory): + grant_repo.revoke.return_value = True + + c = _app_factory(auth_svc, grant_repo, authed_user) + resp = c.post( + "/auth/device/revoke", + data={"app_id": "spoo-snap"}, + headers={"X-Requested-With": "fetch"}, + ) + assert resp.status_code == 200 + assert resp.json()["success"] is True + grant_repo.revoke.assert_awaited_once() + auth_svc.revoke_device_tokens.assert_awaited_once() + + +def test_revoke_no_grant_returns_404(auth_svc, grant_repo, authed_user, _app_factory): + grant_repo.revoke.return_value = False + + c = _app_factory(auth_svc, grant_repo, authed_user) + resp = c.post( + "/auth/device/revoke", + data={"app_id": "spoo-snap"}, + headers={"X-Requested-With": "fetch"}, + ) + assert resp.status_code == 404 + + +# ── POST /auth/device/refresh ───────────────────────────────────────────────── + + +def test_device_refresh_success(auth_svc, grant_repo, _app_factory): + user = _make_user_doc() + auth_svc.refresh_token.return_value = (user, "new-at", "new-rt", "spoo-snap") + grant_repo.find_active_grant.return_value = _make_grant() + + c = _app_factory(auth_svc, grant_repo) + resp = c.post("/auth/device/refresh", json={"refresh_token": "old-rt"}) + assert resp.status_code == 200 + data = resp.json() + assert data["access_token"] == "new-at" + assert data["refresh_token"] == "new-rt" + grant_repo.touch_last_used.assert_awaited_once() + + +def test_device_refresh_no_app_id_skips_grant_check(auth_svc, grant_repo, _app_factory): + user = _make_user_doc() + auth_svc.refresh_token.return_value = (user, "new-at", "new-rt", None) + + c = _app_factory(auth_svc, grant_repo) + resp = c.post("/auth/device/refresh", json={"refresh_token": "old-rt"}) + assert resp.status_code == 200 + grant_repo.find_active_grant.assert_not_awaited() + + +def test_device_refresh_revoked_grant_rejected(auth_svc, grant_repo, _app_factory): + user = _make_user_doc() + auth_svc.refresh_token.return_value = (user, "new-at", "new-rt", "spoo-snap") + grant_repo.find_active_grant.return_value = None + + c = _app_factory(auth_svc, grant_repo) + resp = c.post("/auth/device/refresh", json={"refresh_token": "old-rt"}) assert resp.status_code == 401 + assert "revoked" in resp.json()["error"].lower() + grant_repo.touch_last_used.assert_not_awaited() diff --git a/tests/unit/repositories/test_app_grant_repository.py b/tests/unit/repositories/test_app_grant_repository.py new file mode 100644 index 00000000..e9cf9f31 --- /dev/null +++ b/tests/unit/repositories/test_app_grant_repository.py @@ -0,0 +1,214 @@ +"""Unit tests for AppGrantRepository.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest +from bson import ObjectId +from pymongo.errors import PyMongoError + +from repositories.app_grant_repository import AppGrantRepository + +from .conftest import USER_OID, make_collection + + +class _AsyncIter: + """Minimal async iterator wrapping a list for mocking cursor iteration.""" + + def __init__(self, items): + self._items = iter(items) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._items) + except StopIteration: + raise StopAsyncIteration from None + + +_APP_ID = "spoo-snap" +_GRANT_OID = ObjectId("eeeeeeeeeeeeeeeeeeeeeeee") + + +def _grant_doc( + revoked: bool = False, + app_id: str = _APP_ID, + last_used: datetime | None = None, +) -> dict: + now = datetime.now(timezone.utc) + return { + "_id": _GRANT_OID, + "user_id": USER_OID, + "app_id": app_id, + "granted_at": now, + "last_used_at": last_used, + "revoked_at": now if revoked else None, + } + + +def _repo(col=None) -> AppGrantRepository: + return AppGrantRepository(col or make_collection()) + + +# ── find_active_grant ───────────────────────────────────────────────────────── + + +class TestFindActiveGrant: + @pytest.mark.asyncio + async def test_returns_model_when_found(self): + col = make_collection() + col.find_one = AsyncMock(return_value=_grant_doc()) + result = await _repo(col).find_active_grant(USER_OID, _APP_ID) + col.find_one.assert_awaited_once_with( + {"user_id": USER_OID, "app_id": _APP_ID, "revoked_at": None} + ) + assert result is not None + assert result.app_id == _APP_ID + assert result.user_id == USER_OID + + @pytest.mark.asyncio + async def test_returns_none_on_miss(self): + col = make_collection() + col.find_one = AsyncMock(return_value=None) + assert await _repo(col).find_active_grant(USER_OID, _APP_ID) is None + + @pytest.mark.asyncio + async def test_propagates_pymongo_error(self): + col = make_collection() + col.find_one = AsyncMock(side_effect=PyMongoError("conn lost")) + with pytest.raises(PyMongoError): + await _repo(col).find_active_grant(USER_OID, _APP_ID) + + +# ── find_active_for_user ────────────────────────────────────────────────────── + + +class TestFindActiveForUser: + @pytest.mark.asyncio + async def test_returns_list(self): + col = make_collection() + docs = [_grant_doc(), _grant_doc(app_id="spoo-desktop")] + col.find = MagicMock(return_value=_AsyncIter(docs)) + + results = await _repo(col).find_active_for_user(USER_OID) + col.find.assert_called_once_with({"user_id": USER_OID, "revoked_at": None}) + assert len(results) == 2 + + @pytest.mark.asyncio + async def test_returns_empty_list(self): + col = make_collection() + col.find = MagicMock(return_value=_AsyncIter([])) + + results = await _repo(col).find_active_for_user(USER_OID) + assert results == [] + + @pytest.mark.asyncio + async def test_propagates_pymongo_error(self): + col = make_collection() + col.find = MagicMock(side_effect=PyMongoError("fail")) + with pytest.raises(PyMongoError): + await _repo(col).find_active_for_user(USER_OID) + + +# ── find_all_for_user ───────────────────────────────────────────────────────── + + +class TestFindAllForUser: + @pytest.mark.asyncio + async def test_includes_revoked(self): + col = make_collection() + docs = [_grant_doc(), _grant_doc(revoked=True)] + col.find = MagicMock(return_value=_AsyncIter(docs)) + + results = await _repo(col).find_all_for_user(USER_OID) + col.find.assert_called_once_with({"user_id": USER_OID}) + assert len(results) == 2 + + +# ── create_or_reactivate ────────────────────────────────────────────────────── + + +class TestCreateOrReactivate: + @pytest.mark.asyncio + async def test_upserts_and_returns_model(self): + col = make_collection() + col.find_one_and_update = AsyncMock(return_value=_grant_doc()) + result = await _repo(col).create_or_reactivate(USER_OID, _APP_ID) + + call_args = col.find_one_and_update.await_args + assert call_args[0][0] == {"user_id": USER_OID, "app_id": _APP_ID} + update = call_args[0][1] + assert "granted_at" in update["$set"] + assert update["$set"]["revoked_at"] is None + assert update["$setOnInsert"]["user_id"] == USER_OID + assert call_args[1]["upsert"] is True + assert call_args[1]["return_document"] is True + + assert result is not None + assert result.app_id == _APP_ID + + @pytest.mark.asyncio + async def test_propagates_pymongo_error(self): + col = make_collection() + col.find_one_and_update = AsyncMock(side_effect=PyMongoError("dup")) + with pytest.raises(PyMongoError): + await _repo(col).create_or_reactivate(USER_OID, _APP_ID) + + +# ── revoke ──────────────────────────────────────────────────────────────────── + + +class TestRevoke: + @pytest.mark.asyncio + async def test_returns_true_when_revoked(self): + col = make_collection() + col.update_one = AsyncMock(return_value=MagicMock(modified_count=1)) + assert await _repo(col).revoke(USER_OID, _APP_ID) is True + call_args = col.update_one.await_args[0] + assert call_args[0] == { + "user_id": USER_OID, + "app_id": _APP_ID, + "revoked_at": None, + } + + @pytest.mark.asyncio + async def test_returns_false_when_no_grant(self): + col = make_collection() + col.update_one = AsyncMock(return_value=MagicMock(modified_count=0)) + assert await _repo(col).revoke(USER_OID, _APP_ID) is False + + @pytest.mark.asyncio + async def test_propagates_pymongo_error(self): + col = make_collection() + col.update_one = AsyncMock(side_effect=PyMongoError("fail")) + with pytest.raises(PyMongoError): + await _repo(col).revoke(USER_OID, _APP_ID) + + +# ── touch_last_used ─────────────────────────────────────────────────────────── + + +class TestTouchLastUsed: + @pytest.mark.asyncio + async def test_updates_active_grant(self): + col = make_collection() + col.update_one = AsyncMock(return_value=MagicMock(modified_count=1)) + await _repo(col).touch_last_used(USER_OID, _APP_ID) + call_args = col.update_one.await_args[0] + assert call_args[0] == { + "user_id": USER_OID, + "app_id": _APP_ID, + "revoked_at": None, + } + assert "last_used_at" in call_args[1]["$set"] + + @pytest.mark.asyncio + async def test_propagates_pymongo_error(self): + col = make_collection() + col.update_one = AsyncMock(side_effect=PyMongoError("fail")) + with pytest.raises(PyMongoError): + await _repo(col).touch_last_used(USER_OID, _APP_ID) diff --git a/tests/unit/repositories/test_indexes.py b/tests/unit/repositories/test_indexes.py index 822cabb2..103311f5 100644 --- a/tests/unit/repositories/test_indexes.py +++ b/tests/unit/repositories/test_indexes.py @@ -21,12 +21,15 @@ async def test_ensure_indexes_calls_create_index(self): api_keys_col = AsyncMock() tokens_col = AsyncMock() + app_grants_col = AsyncMock() + db.__getitem__ = lambda self, name: { "users": users_col, "urlsV2": urls_v2_col, "clicks": clicks_col, "api-keys": api_keys_col, "verification-tokens": tokens_col, + "app-grants": app_grants_col, }[name] # create_collection raises CollectionInvalid when collection already exists @@ -48,6 +51,13 @@ async def test_ensure_indexes_calls_create_index(self): tokens_col.create_index.assert_any_await( [("expires_at", 1)], expireAfterSeconds=0 ) + app_grants_col.create_index.assert_any_await( + [("user_id", 1), ("app_id", 1)], unique=True + ) + app_grants_col.create_index.assert_any_await( + [("user_id", 1), ("revoked_at", 1)] + ) + app_grants_col.create_index.assert_any_await([("app_id", 1), ("revoked_at", 1)]) @pytest.mark.asyncio async def test_ensure_indexes_creates_timeseries_collection(self): diff --git a/tests/unit/services/test_auth_service.py b/tests/unit/services/test_auth_service.py index 2ddee5ee..d9905f5e 100644 --- a/tests/unit/services/test_auth_service.py +++ b/tests/unit/services/test_auth_service.py @@ -321,10 +321,13 @@ async def test_refresh_token_success(self): refresh_tok = svc._generate_refresh_token(user, amr="pwd") svc._user_repo.find_by_id.return_value = user - result_user, new_access, new_refresh = await svc.refresh_token(refresh_tok) + result_user, new_access, new_refresh, app_id = await svc.refresh_token( + refresh_tok + ) assert result_user is user assert isinstance(new_access, str) assert isinstance(new_refresh, str) + assert app_id is None @pytest.mark.asyncio async def test_refresh_token_invalid_raises(self): @@ -887,9 +890,10 @@ async def test_exchange_device_code_success(self): svc._token_repo.consume_by_hash.return_value = token_doc svc._user_repo.find_by_id.return_value = make_user_doc(email_verified=True) - _user, access, refresh = await svc.exchange_device_code(raw_code) + _user, access, refresh, app_id = await svc.exchange_device_code(raw_code) assert isinstance(access, str) assert isinstance(refresh, str) + assert app_id is None # no app_id set on this token svc._token_repo.consume_by_hash.assert_awaited_once() @pytest.mark.asyncio diff --git a/tests/unit/shared/test_app_registry.py b/tests/unit/shared/test_app_registry.py new file mode 100644 index 00000000..2c33c2e4 --- /dev/null +++ b/tests/unit/shared/test_app_registry.py @@ -0,0 +1,134 @@ +"""Unit tests for shared.app_registry.load_app_registry.""" + +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest + +from shared.app_registry import load_app_registry + + +@pytest.fixture() +def yaml_file(tmp_path: Path) -> Path: + """Return a path to a temp YAML file (caller writes content).""" + return tmp_path / "apps.yaml" + + +def _write(path: Path, content: str) -> Path: + path.write_text(dedent(content).lstrip()) + return path + + +class TestLoadAppRegistry: + def test_loads_valid_registry(self, yaml_file: Path): + _write( + yaml_file, + """ + apps: + spoo-snap: + name: Spoo Snap + description: Browser extension + verified: true + status: live + type: device_auth + spoo-cli: + name: Spoo CLI + description: Terminal tool + status: coming_soon + type: device_auth + """, + ) + registry = load_app_registry(yaml_file) + assert len(registry) == 2 + assert "spoo-snap" in registry + assert "spoo-cli" in registry + assert registry["spoo-snap"].name == "Spoo Snap" + assert registry["spoo-snap"].is_live_device_app() is True + assert registry["spoo-cli"].is_live_device_app() is False + + def test_returns_empty_dict_when_file_missing(self, tmp_path: Path): + registry = load_app_registry(tmp_path / "nope.yaml") + assert registry == {} + + def test_returns_empty_dict_on_invalid_yaml(self, yaml_file: Path): + _write(yaml_file, ":::not valid yaml:::") + registry = load_app_registry(yaml_file) + assert registry == {} + + def test_returns_empty_dict_when_no_apps_key(self, yaml_file: Path): + _write(yaml_file, "something_else: true\n") + registry = load_app_registry(yaml_file) + assert registry == {} + + def test_returns_empty_dict_when_apps_is_empty(self, yaml_file: Path): + _write(yaml_file, "apps: {}\n") + registry = load_app_registry(yaml_file) + assert registry == {} + + def test_skips_invalid_entries(self, yaml_file: Path): + _write( + yaml_file, + """ + apps: + good-app: + name: Good + description: Works fine + bad-app: + name: "" + description: "" + """, + ) + registry = load_app_registry(yaml_file) + assert "good-app" in registry + assert "bad-app" not in registry + + def test_validates_icon_for_live_apps(self, yaml_file: Path, tmp_path: Path): + _write( + yaml_file, + """ + apps: + my-app: + name: My App + description: Has an icon + icon: my-icon.svg + status: live + type: device_auth + """, + ) + # Icon doesn't exist — should still load (just warns) + registry = load_app_registry(yaml_file) + assert "my-app" in registry + + def test_preserves_all_fields(self, yaml_file: Path): + _write( + yaml_file, + """ + apps: + test-app: + name: Test + description: Full fields + verified: true + status: live + type: device_auth + redirect_uris: + - http://localhost:9000/cb + links: + chrome: https://chrome.google.com + permissions: + - Access your account + - Create short URLs + """, + ) + registry = load_app_registry(yaml_file) + app = registry["test-app"] + assert app.verified is True + assert app.redirect_uris == ["http://localhost:9000/cb"] + assert app.links == {"chrome": "https://chrome.google.com"} + assert len(app.permissions) == 2 + + def test_returns_empty_dict_for_non_dict_file(self, yaml_file: Path): + _write(yaml_file, "- just\n- a\n- list\n") + registry = load_app_registry(yaml_file) + assert registry == {}