Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 17 additions & 31 deletions app/api/routers/breeze_buddy/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 41 additions & 14 deletions app/api/routers/breeze_buddy/auth/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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,
}
2 changes: 1 addition & 1 deletion app/api/routers/breeze_buddy/stt/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/api/routers/breeze_buddy/webhooks/breeze/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
2 changes: 1 addition & 1 deletion app/api/routers/feature_flags/rbac.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
50 changes: 46 additions & 4 deletions app/api/security/breeze_buddy/rbac_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:<id>") 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")
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
47 changes: 47 additions & 0 deletions app/core/security/token_revocation.py
Original file line number Diff line number Diff line change
@@ -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
Loading