Files

238 lines
8.9 KiB
Python
Raw Permalink Normal View History

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import jwt
import time
from typing import Optional, Dict, Any
from fastapi import Request, HTTPException, Depends
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logger import get_logger
from app.database import get_db
logger = get_logger(__name__)
class ShopifySessionService:
"""Service for handling Shopify session token validation"""
@staticmethod
def get_session_token_from_request(request: Request) -> Optional[str]:
"""
Get session token from request header or URL parameter.
Per Shopify docs, session token can be in:
1. Authorization header: Bearer <token>
2. URL query parameter: id_token
"""
# Try to get from Authorization header first
auth_header = request.headers.get('authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header.replace('Bearer ', '').strip()
if token:
return token
# Try to get from URL query parameter
id_token = request.query_params.get('id_token')
if id_token:
return id_token
return None
@staticmethod
def validate_session_token(token: str) -> Dict[str, Any]:
"""
Validate and decode Shopify session token.
Returns decoded token payload if valid.
Raises HTTPException if invalid.
"""
try:
# Verify the token with Shopify's public key
# Note: In production, you should fetch and cache Shopify's public keys
# For now, we'll do basic validation
decoded = jwt.decode(
token,
settings.SHOPIFY_API_SECRET,
algorithms=["HS256"],
audience=settings.SHOPIFY_API_KEY
)
# Check expiration
exp = decoded.get('exp')
if exp and exp < time.time():
raise HTTPException(status_code=401, detail="Session token expired")
# Check nbf (not before)
nbf = decoded.get('nbf')
if nbf and nbf > time.time():
raise HTTPException(status_code=401, detail="Session token not yet valid")
logger.info(f"Session token validated for shop: {decoded.get('dest')}")
return decoded
except jwt.ExpiredSignatureError:
logger.warning("Session token expired")
raise HTTPException(
status_code=401,
detail="Session token expired",
headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}
)
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid session token: {str(e)}")
raise HTTPException(
status_code=401,
detail="Invalid session token",
headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}
)
@staticmethod
def is_document_request(request: Request) -> bool:
"""
Check if this is a document request (initial page load) vs API request.
Document requests typically don't have Authorization header.
"""
return not request.headers.get("authorization")
# FastAPI dependency for session token validation
async def require_shopify_session(
request: Request,
db: Session = Depends(get_db)
) -> Dict[str, Any]:
"""
FastAPI dependency to validate Shopify session token.
Extracts shop from token and looks up organization.
Returns decoded token with db_shop and organization_id.
Usage:
@router.get("/some-endpoint")
async def some_endpoint(
shopify_session: dict = Depends(require_shopify_session),
db: Session = Depends(get_db)
):
shop_id = shopify_session['shop_id']
org_id = shopify_session['organization_id']
db_shop = shopify_session['db_shop']
# ... use the shop info
"""
from app.repositories.shopify_shop_repository import ShopifyShopRepository
session_token = ShopifySessionService.get_session_token_from_request(request)
if not session_token:
raise HTTPException(
status_code=401,
detail="Session token required for embedded app",
headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}
)
# Validate and decode token
decoded = ShopifySessionService.validate_session_token(session_token)
# Extract shop domain from token
shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '')
# Look up shop in database
shop_repository = ShopifyShopRepository(db)
db_shop = shop_repository.get_shop_by_domain(shop_domain)
if not db_shop or not db_shop.is_installed:
raise HTTPException(status_code=403, detail="Shop not installed or not found")
# Add shop info to decoded token for easy access
decoded['db_shop'] = db_shop
decoded['shop_id'] = str(db_shop.id)
decoded['shop_domain'] = shop_domain
decoded['organization_id'] = db_shop.organization_id if db_shop.organization_id else None
logger.info(f"Session token validated for shop: {shop_domain}, org: {decoded['organization_id']}")
return decoded
async def require_shopify_or_jwt_auth(
request: Request,
db: Session = Depends(get_db)
) -> Dict[str, Any]:
"""
Hybrid authentication dependency that tries Shopify session token first,
then falls back to JWT authentication.
Returns authentication info with type indicator.
Usage:
@router.get("/some-endpoint")
async def some_endpoint(
auth_info: dict = Depends(require_shopify_or_jwt_auth),
db: Session = Depends(get_db)
):
if auth_info['auth_type'] == 'shopify_session':
org_id = auth_info['organization_id']
shop_id = auth_info['shop_id']
user_id = None # No user for session token auth
else: # jwt
org_id = auth_info['organization_id']
user_id = auth_info['user_id']
current_user = auth_info['current_user']
"""
from app.core.auth import get_current_user
from app.repositories.shopify_shop_repository import ShopifyShopRepository
# First try Shopify session token
session_token = ShopifySessionService.get_session_token_from_request(request)
if session_token:
try:
# Validate and decode the session token
decoded = ShopifySessionService.validate_session_token(session_token)
# Extract shop domain from token
shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '')
if shop_domain:
# Look up shop in database
shop_repository = ShopifyShopRepository(db)
db_shop = shop_repository.get_shop_by_domain(shop_domain)
if db_shop and db_shop.is_installed and db_shop.organization_id:
logger.info(f"Using Shopify session token auth for shop: {shop_domain}")
return {
'auth_type': 'shopify_session',
'shop_domain': shop_domain,
'shop_id': str(db_shop.id),
'organization_id': db_shop.organization_id, # Keep as UUID for consistency
'db_shop': db_shop,
'decoded_token': decoded,
'user_id': None # No user_id for session token auth
}
except Exception as e:
logger.debug(f"Shopify session token validation failed, trying JWT: {str(e)}")
# Fall back to JWT authentication
try:
current_user = await get_current_user(request=request, db=db)
logger.info(f"Using JWT auth for user: {current_user.id}")
return {
'auth_type': 'jwt',
'organization_id': current_user.organization_id, # Keep as UUID for consistency
'user_id': current_user.id,
'current_user': current_user
}
except Exception as e:
logger.error(f"Both Shopify session token and JWT authentication failed: {str(e)}")
raise HTTPException(
status_code=401,
detail="Authentication required"
)