mantimon-tcg/backend/app/services/jwt_service.py
Cal Corum 996c43fbd9 Implement Phase 2: Authentication system
Complete OAuth-based authentication with JWT session management:

Core Services:
- JWT service for access/refresh token creation and verification
- Token store with Redis-backed refresh token revocation
- User service for CRUD operations and OAuth-based creation
- Google and Discord OAuth services with full flow support

API Endpoints:
- GET /api/auth/{google,discord} - Start OAuth flows
- GET /api/auth/{google,discord}/callback - Handle OAuth callbacks
- POST /api/auth/refresh - Exchange refresh token for new access token
- POST /api/auth/logout - Revoke single refresh token
- POST /api/auth/logout-all - Revoke all user sessions
- GET/PATCH /api/users/me - User profile management
- GET /api/users/me/linked-accounts - List OAuth providers
- GET /api/users/me/sessions - Count active sessions

Infrastructure:
- Pydantic schemas for auth/user request/response models
- FastAPI dependencies (get_current_user, get_current_premium_user)
- OAuthLinkedAccount model for multi-provider support
- Alembic migration for oauth_linked_accounts table

Dependencies added: email-validator, fakeredis (dev), respx (dev)

84 new tests, 1058 total passing
2026-01-27 21:49:59 -06:00

207 lines
5.2 KiB
Python

"""JWT token service for Mantimon TCG.
This module provides functions for creating and verifying JWT tokens
used in the authentication system.
Token Types:
- Access tokens: Short-lived (30 min default), used for API authentication
- Refresh tokens: Longer-lived (7 days default), used to obtain new access tokens
Example:
from app.services.jwt_service import create_access_token, verify_token
# Create tokens
access_token = create_access_token(user_id)
refresh_token, jti = create_refresh_token(user_id)
# Verify token
user_id = verify_token(access_token)
if user_id:
print(f"Valid token for user {user_id}")
"""
import uuid
from datetime import UTC, datetime, timedelta
from jose import JWTError, jwt
from app.config import settings
from app.schemas.auth import TokenPayload, TokenType
def create_access_token(user_id: uuid.UUID) -> str:
"""Create a short-lived JWT access token.
Args:
user_id: The user's UUID to encode in the token.
Returns:
Encoded JWT access token string.
Example:
token = create_access_token(user.id)
# Use token in Authorization header: Bearer {token}
"""
now = datetime.now(UTC)
expires = now + timedelta(minutes=settings.jwt_expire_minutes)
payload = {
"sub": str(user_id),
"exp": expires,
"iat": now,
"type": TokenType.ACCESS.value,
}
return jwt.encode(
payload,
settings.secret_key.get_secret_value(),
algorithm=settings.jwt_algorithm,
)
def create_refresh_token(user_id: uuid.UUID) -> tuple[str, str]:
"""Create a longer-lived JWT refresh token.
Refresh tokens include a JTI (JWT ID) for tracking and revocation.
Args:
user_id: The user's UUID to encode in the token.
Returns:
Tuple of (encoded JWT refresh token, JTI string).
The JTI should be stored in Redis for revocation tracking.
Example:
token, jti = create_refresh_token(user.id)
await token_store.store_refresh_token(user.id, jti, expires_at)
"""
now = datetime.now(UTC)
expires = now + timedelta(days=settings.jwt_refresh_expire_days)
jti = str(uuid.uuid4())
payload = {
"sub": str(user_id),
"exp": expires,
"iat": now,
"type": TokenType.REFRESH.value,
"jti": jti,
}
token = jwt.encode(
payload,
settings.secret_key.get_secret_value(),
algorithm=settings.jwt_algorithm,
)
return token, jti
def decode_token(token: str) -> TokenPayload | None:
"""Decode and validate a JWT token.
Args:
token: The JWT token string to decode.
Returns:
TokenPayload if valid, None if invalid or expired.
Example:
payload = decode_token(token)
if payload:
user_id = UUID(payload.sub)
"""
try:
payload_dict = jwt.decode(
token,
settings.secret_key.get_secret_value(),
algorithms=[settings.jwt_algorithm],
)
return TokenPayload(**payload_dict)
except JWTError:
return None
def verify_access_token(token: str) -> uuid.UUID | None:
"""Verify an access token and extract the user ID.
Args:
token: The JWT access token to verify.
Returns:
User UUID if valid access token, None otherwise.
Example:
user_id = verify_access_token(token)
if user_id:
user = await user_service.get_user_by_id(db, user_id)
"""
payload = decode_token(token)
if payload is None:
return None
if payload.type != TokenType.ACCESS:
return None
try:
return uuid.UUID(payload.sub)
except ValueError:
return None
def verify_refresh_token(token: str) -> tuple[uuid.UUID, str] | None:
"""Verify a refresh token and extract user ID and JTI.
The JTI should be checked against the token store to ensure
the token hasn't been revoked.
Args:
token: The JWT refresh token to verify.
Returns:
Tuple of (user UUID, JTI) if valid refresh token, None otherwise.
Example:
result = verify_refresh_token(token)
if result:
user_id, jti = result
if await token_store.is_token_valid(user_id, jti):
# Issue new access token
"""
payload = decode_token(token)
if payload is None:
return None
if payload.type != TokenType.REFRESH:
return None
if payload.jti is None:
return None
try:
user_id = uuid.UUID(payload.sub)
return user_id, payload.jti
except ValueError:
return None
def get_token_expiration_seconds() -> int:
"""Get the access token expiration time in seconds.
Useful for the expires_in field in token responses.
Returns:
Access token lifetime in seconds.
"""
return settings.jwt_expire_minutes * 60
def get_refresh_token_expiration() -> datetime:
"""Get the expiration datetime for a new refresh token.
Useful for setting TTL when storing in Redis.
Returns:
Datetime when a refresh token created now would expire.
"""
return datetime.now(UTC) + timedelta(days=settings.jwt_refresh_expire_days)