diff --git a/app/api/routers/breeze_buddy/auth/__init__.py b/app/api/routers/breeze_buddy/auth/__init__.py index a323ca98d..c9a529d52 100644 --- a/app/api/routers/breeze_buddy/auth/__init__.py +++ b/app/api/routers/breeze_buddy/auth/__init__.py @@ -15,9 +15,13 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import RedirectResponse +from fastapi.security import HTTPAuthorizationCredentials from app.api.routers.breeze_buddy.auth.rate_limit import enforce_credential_rate_limit -from app.api.security.breeze_buddy.rbac_token import get_current_user_with_rbac +from app.api.security.breeze_buddy.rbac_token import ( + get_current_user_with_rbac, + security, +) from app.schemas import ( LaunchTokenRequest, LaunchTokenResponse, @@ -204,44 +208,26 @@ async def get_current_user_info( @router.post("/auth/logout") -async def logout_user(): +async def logout_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + current_user: UserInfo = Depends(get_current_user_with_rbac), +): """ - Logout endpoint for JWT token-based authentication. + Log out a JWT-authenticated user. - Since JWT tokens are stateless and stored client-side: - - Backend cannot invalidate the token (no session to destroy) - - Client must delete the token from localStorage/cookies - - Token will naturally expire after its lifetime - - This endpoint exists for: - - API consistency (REST convention) - - Future enhancements (e.g., token blacklisting) - - Logging logout events - - Client-side logout steps: - 1. Call this endpoint (optional, for logging) - 2. Remove token from localStorage/cookies - 3. Redirect to login page - 4. Clear any user state in application + The presented token is added to the server-side revocation denylist (keyed + by a hash of the token, with a TTL equal to its remaining lifetime), so it + can no longer authenticate even though its signature stays valid until its + natural expiry (PT-22). Clients should still discard their stored copy. Returns: { "success": true, - "message": "Logout acknowledged. Client should clear token from storage.", - "instructions": { - "step_1": "Remove token from localStorage or cookies", - "step_2": "Clear user state in your application", - "step_3": "Redirect to login page", - "note": "Token remains valid until expiration but client discards it" - } + "message": "Logout successful. Token has been revoked server-side.", + "revoked": true } - - Note: - The actual logout happens client-side by removing the token. - The token remains technically valid until expiration, but the client - discards it and can no longer use it. """ - return await logout_handler() + return await logout_handler(credentials.credentials) @router.get("/logout", include_in_schema=False) diff --git a/app/api/routers/breeze_buddy/auth/handlers.py b/app/api/routers/breeze_buddy/auth/handlers.py index 7f78e0845..5d36dddc1 100644 --- a/app/api/routers/breeze_buddy/auth/handlers.py +++ b/app/api/routers/breeze_buddy/auth/handlers.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone from urllib.parse import quote +import jwt as pyjwt from fastapi import HTTPException, status from app.api.security.breeze_buddy.rbac_token import rbac_token_manager @@ -22,6 +23,7 @@ from app.core.logger import logger from app.core.security.password import DUMMY_PASSWORD_HASH, verify_password_async from app.core.security.scope import resolve_merchant_ids, resolve_reseller_ids +from app.core.security.token_revocation import revoke_token from app.database.accessor.breeze_buddy import merchants as merchant_accessors from app.database.accessor.breeze_buddy.users import get_user_by_username from app.schemas import ( @@ -431,27 +433,52 @@ async def launch_token_handler( ) -async def logout_handler() -> dict: +async def logout_handler(token: str) -> dict: """ Handle logout for JWT token-based authentication. - Since JWT tokens are stateless and stored client-side: - - Backend cannot invalidate the token (no session to destroy) - - Client must delete the token from localStorage/cookies - - Token will naturally expire after its lifetime + The token is added to the server-side revocation denylist (keyed by a hash + of the token, with a TTL equal to its remaining lifetime), so it can no + longer authenticate even though its signature is still valid (PT-22). Returns: - Success message with logout instructions + Success message. """ - logger.info("Logout endpoint called (token-based auth - client-side logout)") + try: + payload = pyjwt.decode( + token, + rbac_token_manager.jwt_manager.secret_key, + algorithms=[rbac_token_manager.jwt_manager.algorithm], + options={"verify_exp": False}, + ) + exp = int(payload.get("exp", 0)) + revoked = await revoke_token(token, exp) + except Exception as e: + logger.error(f"Logout: failed to revoke token: {e}") + revoked = False + + logger.info(f"Logout endpoint called (token revoked={revoked})") + + if not revoked: + # Do not claim a revocation that did not happen: revoke_token returns + # False when the denylist write fails (RedisService.setex swallows + # RedisError), and the token stays valid until it expires. A client + # that believed the old message would treat a live token as dead. + logger.error( + "Logout: token could NOT be added to the revocation denylist; it " + "remains valid until expiry" + ) + return { + "success": False, + "message": ( + "Logged out locally, but the token could not be revoked " + "server-side and remains valid until it expires." + ), + "revoked": False, + } return { "success": True, - "message": "Logout acknowledged. Client should clear token from storage.", - "instructions": { - "step_1": "Remove token from localStorage or cookies", - "step_2": "Clear user state in your application", - "step_3": "Redirect to login page", - "note": "Token remains valid until expiration but client discards it", - }, + "message": "Logout successful. Token has been revoked server-side.", + "revoked": True, } diff --git a/app/api/routers/breeze_buddy/stt/handlers.py b/app/api/routers/breeze_buddy/stt/handlers.py index 5e02397b2..dee846371 100644 --- a/app/api/routers/breeze_buddy/stt/handlers.py +++ b/app/api/routers/breeze_buddy/stt/handlers.py @@ -256,7 +256,7 @@ async def handle_transcription_stream(ws: WebSocket) -> None: await ws.accept() try: - user = get_user_from_websocket(ws) + user = await get_user_from_websocket(ws) except HTTPException as e: await _reject_stream(ws, _WS_UNAUTHORIZED, str(e.detail), "unauthorized") return diff --git a/app/api/routers/breeze_buddy/webhooks/breeze/services.py b/app/api/routers/breeze_buddy/webhooks/breeze/services.py index be01852e0..14218395b 100644 --- a/app/api/routers/breeze_buddy/webhooks/breeze/services.py +++ b/app/api/routers/breeze_buddy/webhooks/breeze/services.py @@ -87,7 +87,7 @@ async def handle_breeze( # rejected even though it still matches the presented value (this is how it # is "revoked": let it expire or re-register the merchant to mint a fresh # one). - rbac_token_manager.verify_rbac_token(stored_token) + await rbac_token_manager.verify_rbac_token(stored_token) try: order = json.loads(raw_body) if raw_body else {} diff --git a/app/api/routers/breeze_buddy/webhooks/woocommerce/services.py b/app/api/routers/breeze_buddy/webhooks/woocommerce/services.py index 87a76bb7b..1df50f393 100644 --- a/app/api/routers/breeze_buddy/webhooks/woocommerce/services.py +++ b/app/api/routers/breeze_buddy/webhooks/woocommerce/services.py @@ -89,7 +89,7 @@ async def handle_woocommerce( # The stored token is a JWT — verify it so an expired/rotated token is # rejected even though its HMAC still matches (this is how it is "revoked": # let it expire or re-register the merchant to mint a fresh one). - rbac_token_manager.verify_rbac_token(token) + await rbac_token_manager.verify_rbac_token(token) try: order = json.loads(raw_body) if raw_body else {} diff --git a/app/api/routers/feature_flags/rbac.py b/app/api/routers/feature_flags/rbac.py index 171f4d954..6ad051dab 100644 --- a/app/api/routers/feature_flags/rbac.py +++ b/app/api/routers/feature_flags/rbac.py @@ -17,7 +17,7 @@ async def get_current_user_with_role( status_code=401, detail="Authorization header missing", ) - return rbac_token_manager.verify_rbac_token(credentials.credentials) + return await rbac_token_manager.verify_rbac_token(credentials.credentials) def require_admin(current_user: UserInfo) -> None: diff --git a/app/api/security/breeze_buddy/rbac_token.py b/app/api/security/breeze_buddy/rbac_token.py index 377092ec2..5ddc03e37 100644 --- a/app/api/security/breeze_buddy/rbac_token.py +++ b/app/api/security/breeze_buddy/rbac_token.py @@ -12,6 +12,7 @@ from app.core.logger import logger from app.core.security.jwt import jwt_manager +from app.core.security.token_revocation import is_token_revoked from app.schemas import UserInfo, UserRole # HTTP Bearer security scheme @@ -74,10 +75,14 @@ def create_access_token_with_rbac( # Use the generic JWT manager to create the token return self.jwt_manager.create_access_token(payload, expires_delta) - def verify_rbac_token(self, token: str) -> UserInfo: + async def verify_rbac_token(self, token: str) -> UserInfo: """ Verify and decode a JWT token with Breeze Buddy RBAC information. + Also enforces server-side revocation (denylist) and a per-request user + liveness recheck, so a logged-out/forged token can be killed and a + disabled/deleted account's tokens stop working (PT-22). + Args: token: JWT token string @@ -144,6 +149,43 @@ def verify_rbac_token(self, token: str) -> UserInfo: headers={"WWW-Authenticate": "Bearer"}, ) + # Server-side revocation: a token that was logged out or explicitly + # revoked is rejected regardless of its (still-valid) signature. + if await is_token_revoked(token): + logger.warning(f"RBAC verifier: rejected revoked token for {user_id}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Liveness recheck: a disabled/deleted account's outstanding tokens + # (including long-lived S2S tokens, whose ``sub`` is the minting + # user) must stop working immediately. + # + # Exception: launch tokens (POST /auth/launch-token) authenticate a + # *virtual* merchant subject ("merchant:") that has no backing + # ``users`` row by design, so a users-table liveness probe would + # always fail closed for them. They are bounded instead by a fixed + # 60-minute TTL, the merchant liveness check performed at mint time, + # the signed ``src`` provenance claim, and the revocation denylist + # checked just above (which still applies to them). Keep this + # prefix in lockstep with the ``sub`` minted in launch_token_handler. + # Imported lazily to avoid a module-load import cycle with the DB + # accessor layer. + if not str(user_id).startswith("merchant:"): + from app.database.accessor.breeze_buddy.users import is_user_active + + if not await is_user_active(user_id): + logger.warning( + f"RBAC verifier: rejected token for inactive/deleted user {user_id}" + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + # Extract Breeze Buddy RBAC data # Normalize legacy "shop" role to "user" for old JWTs still in circulation role_str = payload.get("role") @@ -269,10 +311,10 @@ async def get_current_user_with_rbac( headers={"WWW-Authenticate": "Bearer"}, ) - return rbac_token_manager.verify_rbac_token(credentials.credentials) + return await rbac_token_manager.verify_rbac_token(credentials.credentials) -def get_user_from_websocket(websocket: WebSocket) -> UserInfo: +async def get_user_from_websocket(websocket: WebSocket) -> UserInfo: """Authenticate a WebSocket connection with the standard RBAC bearer token. The FastAPI ``Depends(HTTPBearer())`` guards are HTTP-only, so WebSocket @@ -295,7 +337,7 @@ def get_user_from_websocket(websocket: WebSocket) -> UserInfo: status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing credentials", ) - return rbac_token_manager.verify_rbac_token(token) + return await rbac_token_manager.verify_rbac_token(token) async def get_active_user( diff --git a/app/core/security/token_revocation.py b/app/core/security/token_revocation.py new file mode 100644 index 000000000..825786646 --- /dev/null +++ b/app/core/security/token_revocation.py @@ -0,0 +1,47 @@ +""" +Server-side JWT revocation denylist (Redis-backed). + +Keyed by a SHA-256 hash of the raw token, so it retroactively covers every +already-issued token without needing a `jti` claim or a re-mint. Entries are +stored with a TTL equal to the token's remaining lifetime, so the denylist +self-cleans as tokens expire. + +Fail-open on Redis errors: a Redis outage must not lock out every user (tokens +still expire naturally, and this denylist is one layer among several). The +liveness recheck and short-TTL guidance in PT-22 complement it. +""" + +import hashlib +import time + +from app.core.logger import logger +from app.services.redis.client import get_redis_service + +_REVOKED_PREFIX = "jwt:revoked:" + + +def _denylist_key(token: str) -> str: + return _REVOKED_PREFIX + hashlib.sha256(token.encode("utf-8")).hexdigest() + + +async def revoke_token(token: str, exp: int) -> bool: + """Add a token to the denylist until its own expiry. Returns success.""" + ttl = int(exp - time.time()) + if ttl <= 0: + return True # already expired — nothing to revoke + try: + redis = await get_redis_service() + return await redis.setex(_denylist_key(token), "1", ttl_seconds=ttl) + except Exception as e: + logger.error(f"Failed to revoke JWT: {e}") + return False + + +async def is_token_revoked(token: str) -> bool: + """True if the token has been revoked. Fails open (False) on Redis error.""" + try: + redis = await get_redis_service() + return await redis.exists(_denylist_key(token)) + except Exception as e: + logger.error(f"Token revocation check failed (failing open): {e}") + return False diff --git a/app/database/accessor/breeze_buddy/users.py b/app/database/accessor/breeze_buddy/users.py index 9cd48df8d..e5b3fb4f3 100644 --- a/app/database/accessor/breeze_buddy/users.py +++ b/app/database/accessor/breeze_buddy/users.py @@ -34,6 +34,7 @@ ) from app.schemas.breeze_buddy.auth import UserInDB, UserRole from app.schemas.breeze_buddy.users import UserResponse +from app.services.redis.client import get_redis_service async def check_username_exists(username: str) -> bool: @@ -122,6 +123,63 @@ async def get_user_in_db_by_id(user_id: str) -> Optional[UserInDB]: raise +# Short-lived cache so the per-request liveness recheck (PT-22) is not a DB +# round-trip on every authenticated call. +# +# Disabling an account takes effect immediately on the happy path, because +# update/delete call invalidate_user_active_cache(). The TTL bounds the +# WORST case only — if that invalidation is lost (Redis blip, or an account +# disabled by a path that bypasses the accessor), a cached "active" answer can +# survive at most this many seconds. It is deliberately not the primary +# control: token revocation (token_revocation.py) is uncached and is the +# hard-stop for a compromised token. +_USER_ACTIVE_CACHE_TTL = 10 +_USER_ACTIVE_CACHE_PREFIX = "user_active:" + + +async def is_user_active(user_id: str) -> bool: + """Return whether a user exists and is active. + + Cached in Redis for a few seconds. Fails OPEN (returns True) on Redis/DB + errors so an infra blip does not lock out every authenticated request — the + revocation denylist remains the hard-stop for compromised tokens. + """ + cache_key = f"{_USER_ACTIVE_CACHE_PREFIX}{user_id}" + try: + redis = await get_redis_service() + cached = await redis.get(cache_key) + if cached is not None: + return cached == "1" + except Exception as e: + logger.error(f"user-active cache read failed for {user_id}: {e}") + + try: + user = await get_user_in_db_by_id(user_id) + active = bool(user and user.is_active) + except Exception as e: + logger.error(f"user-active DB lookup failed for {user_id} (failing open): {e}") + return True + + try: + redis = await get_redis_service() + await redis.setex( + cache_key, "1" if active else "0", ttl_seconds=_USER_ACTIVE_CACHE_TTL + ) + except Exception as e: + logger.error(f"user-active cache write failed for {user_id}: {e}") + + return active + + +async def invalidate_user_active_cache(user_id: str) -> None: + """Drop the cached liveness flag so a status change takes effect at once.""" + try: + redis = await get_redis_service() + await redis.delete(f"{_USER_ACTIVE_CACHE_PREFIX}{user_id}") + except Exception as e: + logger.error(f"user-active cache invalidation failed for {user_id}: {e}") + + async def create_merchant_and_user_atomically( *, merchant_id: str, @@ -405,6 +463,10 @@ async def update_user( if row: logger.info(f"Updated user: {user_id}") + if is_active is not None: + # Status changed — drop the liveness cache so a disable + # takes effect on the very next request (PT-22). + await invalidate_user_active_cache(user_id) return decode_user(row) return None @@ -434,6 +496,8 @@ async def delete_user(user_id: str) -> bool: if row: logger.info(f"Deleted user: {user_id}") + # Cut off any outstanding tokens for the deleted user immediately. + await invalidate_user_active_cache(user_id) return True check_query = "SELECT role FROM users WHERE id = $1" diff --git a/app/schemas/breeze_buddy/auth.py b/app/schemas/breeze_buddy/auth.py index 140a60096..b7727f9bb 100644 --- a/app/schemas/breeze_buddy/auth.py +++ b/app/schemas/breeze_buddy/auth.py @@ -6,6 +6,13 @@ from pydantic import BaseModel, Field +# PT-21. Every path that mints a long-lived S2S token must bound it by this, +# not by its own literal — there is more than one such path (POST /auth/s2s/token +# and POST /merchant with issue_token=true), and they both hand the value +# straight to rbac_token_manager.create_access_token_with_rbac. A per-schema +# literal is how the two drifted to 365 and 365000 in the first place. +MAX_S2S_TOKEN_LIFETIME_DAYS = 365 + class TokenData(BaseModel): """Token data model for JWT payload (legacy)""" @@ -61,7 +68,10 @@ class LoginRequest(BaseModel): """Login request model""" username: str - password: str + # min_length=1: bcrypt's verifier rejects an empty plaintext with + # ValueError, which would surface as a 500 instead of the generic 401. + # Fail at the schema boundary (422) so it never reaches verify_password. + password: str = Field(..., min_length=1) class LoginResponse(BaseModel): @@ -83,9 +93,13 @@ class S2STokenRequest(BaseModel): """S2S token generation request model""" username: str - password: str + # See LoginRequest.password — empty plaintext must 422, never reach bcrypt. + password: str = Field(..., min_length=1) token_lifetime_days: int = Field( - default=365, ge=1, le=365000, description="Token lifetime in days (1-365000)" + default=MAX_S2S_TOKEN_LIFETIME_DAYS, + ge=1, + le=MAX_S2S_TOKEN_LIFETIME_DAYS, + description=f"Token lifetime in days (1-{MAX_S2S_TOKEN_LIFETIME_DAYS})", ) reseller_ids: Optional[List[str]] = Field( default=None, diff --git a/app/schemas/breeze_buddy/merchants.py b/app/schemas/breeze_buddy/merchants.py index be512b70f..623658f31 100644 --- a/app/schemas/breeze_buddy/merchants.py +++ b/app/schemas/breeze_buddy/merchants.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, Field +from app.schemas.breeze_buddy.auth import MAX_S2S_TOKEN_LIFETIME_DAYS + class MerchantCreate(BaseModel): """Create a new merchant entity (business entity). @@ -35,11 +37,19 @@ class MerchantCreate(BaseModel): "merchant row, and return it (once) in the response. Used e.g. as the " "webhook HMAC secret. Requires a reseller_id.", ) + # PT-21. This mints a real RBAC JWT through the same helper as + # POST /auth/s2s/token (merchants/handlers.py: create_access_token_with_rbac), + # so it takes the same cap. It previously defaulted to 3650 days and allowed + # 365000 — and unlike /auth/s2s/token, which is admin-only, this endpoint is + # reachable by resellers too. token_lifetime_days: int = Field( - default=3650, + default=MAX_S2S_TOKEN_LIFETIME_DAYS, ge=1, - le=365000, - description="Lifetime of the issued token in days (only when issue_token).", + le=MAX_S2S_TOKEN_LIFETIME_DAYS, + description=( + "Lifetime of the issued token in days (only when issue_token). " + f"Capped at {MAX_S2S_TOKEN_LIFETIME_DAYS} (PT-21)." + ), ) diff --git a/tests/test_stt_stream.py b/tests/test_stt_stream.py index 485df9208..e7b94d06e 100644 --- a/tests/test_stt_stream.py +++ b/tests/test_stt_stream.py @@ -17,6 +17,11 @@ _USER = UserInfo(id="user-1", username="tester", role=UserRole.ADMIN) +async def _ws_user(_ws: WebSocket) -> UserInfo: + """Async stand-in for get_user_from_websocket (now a coroutine).""" + return _USER + + class FakeWebSocket: """Minimal stand-in implementing the surface the stream handler uses. @@ -111,7 +116,7 @@ def _make(stt_service, *, sample_rate, on_transcript): setattr(stub, attr, value) return stub - monkeypatch.setattr(handlers, "get_user_from_websocket", lambda ws: _USER) + monkeypatch.setattr(handlers, "get_user_from_websocket", _ws_user) create_mock = AsyncMock(return_value=object()) monkeypatch.setattr(handlers, "create_stt_from_config", create_mock) created["create_stt"] = create_mock @@ -138,25 +143,30 @@ def test_stream_request_normalizes_and_bounds() -> None: ) -def test_websocket_auth_reads_header_then_query( +async def test_websocket_auth_reads_header_then_query( monkeypatch: pytest.MonkeyPatch, ) -> None: seen: list[str] = [] + + async def _verify(token: str) -> UserInfo: + seen.append(token) + return _USER + monkeypatch.setattr( rbac_token.rbac_token_manager, "verify_rbac_token", - lambda token: seen.append(token) or _USER, + _verify, ) ws = FakeWebSocket("", headers={"authorization": "Bearer header-token"}) - assert rbac_token.get_user_from_websocket(as_ws(ws)) is _USER + assert await rbac_token.get_user_from_websocket(as_ws(ws)) is _USER ws = FakeWebSocket("", query={"token": "query-token"}) - assert rbac_token.get_user_from_websocket(as_ws(ws)) is _USER + assert await rbac_token.get_user_from_websocket(as_ws(ws)) is _USER assert seen == ["header-token", "query-token"] with pytest.raises(HTTPException): - rbac_token.get_user_from_websocket(as_ws(FakeWebSocket(""))) + await rbac_token.get_user_from_websocket(as_ws(FakeWebSocket(""))) async def test_stream_rejects_unauthenticated() -> None: @@ -171,7 +181,7 @@ async def test_stream_rejects_unauthenticated() -> None: async def test_stream_rejects_invalid_config( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(handlers, "get_user_from_websocket", lambda ws: _USER) + monkeypatch.setattr(handlers, "get_user_from_websocket", _ws_user) ws = FakeWebSocket(json.dumps({"provider": "not-a-provider"})) await handlers.handle_transcription_stream(as_ws(ws)) @@ -183,7 +193,7 @@ async def test_stream_rejects_invalid_config( async def test_stream_rejects_openai_provider( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(handlers, "get_user_from_websocket", lambda ws: _USER) + monkeypatch.setattr(handlers, "get_user_from_websocket", _ws_user) ws = FakeWebSocket(json.dumps({"provider": "openai"})) await handlers.handle_transcription_stream(as_ws(ws)) @@ -196,7 +206,7 @@ async def test_stream_rejects_openai_provider( async def test_stream_rejects_sarvam_sample_rate_mismatch( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(handlers, "get_user_from_websocket", lambda ws: _USER) + monkeypatch.setattr(handlers, "get_user_from_websocket", _ws_user) ws = FakeWebSocket(json.dumps({"provider": "sarvam", "sample_rate": 8000})) await handlers.handle_transcription_stream(as_ws(ws)) diff --git a/tests/test_token_lifetime_revocation.py b/tests/test_token_lifetime_revocation.py new file mode 100644 index 000000000..6b7803ab2 --- /dev/null +++ b/tests/test_token_lifetime_revocation.py @@ -0,0 +1,113 @@ +"""PT-21 (S2S lifetime cap on both mint paths) and PT-22 (revocation denylist).""" + +from __future__ import annotations + +import pytest + +from app.core.security import token_revocation +from app.schemas import ( + UserInfo, + UserRole, +) + + +def _user(role: str, resellers, merchants, owner_id=None) -> UserInfo: + return UserInfo( + id="u1", + username="u1", + role=UserRole(role), + email=None, + reseller_ids=list(resellers), + merchant_ids=list(merchants), + permissions=[], + owner_id=owner_id, + ) + + +# ── PT-21: S2S token lifetime cap ───────────────────────────────────────── +def test_s2s_token_lifetime_capped_at_365(): + from pydantic import ValidationError + + from app.schemas.breeze_buddy.auth import S2STokenRequest + + # Build via dict so the over-cap value is validated at runtime (not a static + # literal the type checker rejects up front). + over_cap = {"username": "a", "password": "b", "token_lifetime_days": 365000} + with pytest.raises(ValidationError): + S2STokenRequest.model_validate(over_cap) + S2STokenRequest.model_validate( + {"username": "a", "password": "b", "token_lifetime_days": 365} + ) # ok + + +def test_merchant_issue_token_lifetime_capped_at_365(): + """The OTHER S2S mint path. + + POST /merchant with issue_token=true calls the same + rbac_token_manager.create_access_token_with_rbac as /auth/s2s/token, but it + is reachable by resellers, not just admins. It used to default to 3650 days + and allow 365000, so the PT-21 cap covered one of the two paths. + """ + from pydantic import ValidationError + + from app.schemas.breeze_buddy.auth import ( + MAX_S2S_TOKEN_LIFETIME_DAYS, + S2STokenRequest, + ) + from app.schemas.breeze_buddy.merchants import MerchantCreate + + base = {"merchant_id": "mrc001", "reseller_id": "r1", "issue_token": True} + for over_cap in (MAX_S2S_TOKEN_LIFETIME_DAYS + 1, 3650, 365000): + with pytest.raises(ValidationError): + MerchantCreate.model_validate({**base, "token_lifetime_days": over_cap}) + + MerchantCreate.model_validate( + {**base, "token_lifetime_days": MAX_S2S_TOKEN_LIFETIME_DAYS} + ) # ok + # The default is the sharp edge: nobody has to ask for a long-lived token. + assert ( + MerchantCreate.model_validate(base).token_lifetime_days + <= MAX_S2S_TOKEN_LIFETIME_DAYS + ) + # Both paths must read the same constant, or they drift again. + assert ( + MerchantCreate.model_fields["token_lifetime_days"].default + == S2STokenRequest.model_fields["token_lifetime_days"].default + ) + + +# ── PT-22: token revocation denylist ────────────────────────────────────── +class _FakeRedis: + def __init__(self): + self.store = {} + + async def setex(self, key, value, ttl_seconds=None): + self.store[key] = value + return True + + async def exists(self, key): + return key in self.store + + +async def test_revoke_then_is_revoked(monkeypatch): + fake = _FakeRedis() + + async def _get(): + return fake + + monkeypatch.setattr(token_revocation, "get_redis_service", _get) + import time + + token = "sometoken" + assert await token_revocation.is_token_revoked(token) is False + await token_revocation.revoke_token(token, int(time.time()) + 3600) + assert await token_revocation.is_token_revoked(token) is True + assert await token_revocation.is_token_revoked("other") is False + + +async def test_is_token_revoked_fails_open_on_redis_error(monkeypatch): + async def _boom(): + raise RuntimeError("redis down") + + monkeypatch.setattr(token_revocation, "get_redis_service", _boom) + assert await token_revocation.is_token_revoked("x") is False