Compare commits

..

1 Commits

Author SHA1 Message Date
ي
6fdf318d79 Add OAuth token refresh retries, status persistence, and alert payloads 2026-05-18 15:56:57 +05:30
6 changed files with 239 additions and 219 deletions

View File

@@ -40,6 +40,10 @@ class OAuthTokenMonitoringTask(Base):
# Scheduling
next_check = Column(DateTime, nullable=True, index=True) # Next scheduled check time
next_retry_at = Column(DateTime, nullable=True, index=True) # Backoff retry schedule for refresh failures
refresh_attempts = Column(Integer, default=0) # Current retry attempt count for refresh workflow
terminal_failure_reason = Column(Text, nullable=True) # Permanent failure reason requiring user action
channel_status = Column(String(32), default='connected') # connected, degraded, disconnected
# Metadata
created_at = Column(DateTime, default=datetime.utcnow)
@@ -97,4 +101,3 @@ class OAuthTokenExecutionLog(Base):
def __repr__(self):
return f"<OAuthTokenExecutionLog(id={self.id}, task_id={self.task_id}, status={self.status}, execution_date={self.execution_date})>"

View File

@@ -9,27 +9,26 @@ from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from loguru import logger
from services.database import get_user_db_path
from services.token_crypto_service import TokenCryptoService
from services.database import get_user_db_path
class WixOAuthService:
"""Manages Wix OAuth2 authentication flow and token storage."""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path
self.token_crypto = TokenCryptoService()
def _get_db_path(self, user_id: str) -> str:
if self.db_path:
return self.db_path
return get_user_db_path(user_id)
def _init_db(self, user_id: str):
"""Initialize database tables for OAuth tokens."""
db_path = self._get_db_path(user_id)
# Ensure directory exists
os.makedirs(os.path.dirname(db_path), exist_ok=True)
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
@@ -46,133 +45,168 @@ class WixOAuthService:
member_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
token_key_version TEXT,
token_key_reference TEXT
is_active BOOLEAN DEFAULT TRUE
)
''')
for column_name, column_def in [
("token_key_version", "TEXT"),
("token_key_reference", "TEXT"),
]:
try:
cursor.execute(f"ALTER TABLE wix_oauth_tokens ADD COLUMN {column_name} {column_def}")
except sqlite3.OperationalError:
pass
conn.commit()
def store_tokens(self, user_id: str, access_token: str, refresh_token: Optional[str] = None,
expires_in: Optional[int] = None, token_type: str = 'bearer', scope: Optional[str] = None,
site_id: Optional[str] = None, member_id: Optional[str] = None) -> bool:
def store_tokens(
self,
user_id: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None,
token_type: str = 'bearer',
scope: Optional[str] = None,
site_id: Optional[str] = None,
member_id: Optional[str] = None
) -> bool:
"""
Store Wix OAuth tokens in the database.
Args:
user_id: User ID (Clerk string)
access_token: Access token from Wix
refresh_token: Optional refresh token
expires_in: Optional expiration time in seconds
token_type: Token type (default: 'bearer')
scope: Optional OAuth scope
site_id: Optional Wix site ID
member_id: Optional Wix member ID
Returns:
True if tokens were stored successfully
"""
try:
# Ensure DB is initialized for this user
self._init_db(user_id)
db_path = self._get_db_path(user_id)
expires_at = datetime.now() + timedelta(seconds=expires_in) if expires_in else None
encrypted_access_token, encrypted_refresh_token = self.token_crypto.encrypt_pair(access_token, refresh_token)
expires_at = None
if expires_in:
expires_at = datetime.now() + timedelta(seconds=expires_in)
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO wix_oauth_tokens
(user_id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, token_key_version, token_key_reference)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
user_id,
encrypted_access_token,
encrypted_refresh_token,
token_type,
expires_at,
expires_in,
scope,
site_id,
member_id,
self.token_crypto.key_version,
self.token_crypto.key_reference,
))
INSERT INTO wix_oauth_tokens
(user_id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (user_id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id))
conn.commit()
logger.info(f"Wix OAuth: Encrypted token stored for user {user_id}")
logger.info(f"Wix OAuth: Token inserted into database for user {user_id}")
return True
except Exception as e:
logger.error(f"Error storing Wix tokens for user {user_id}: {e}")
return False
def get_user_tokens(self, user_id: str) -> List[Dict[str, Any]]:
"""Get all active Wix token rows (encrypted values)."""
"""Get all active Wix tokens for a user."""
try:
# Ensure database tables exist to prevent 'no such table' errors
self._init_db(user_id)
db_path = self._get_db_path(user_id)
if not os.path.exists(db_path):
return []
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, created_at, token_key_version, token_key_reference
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, created_at
FROM wix_oauth_tokens
WHERE user_id = ? AND is_active = TRUE AND (expires_at IS NULL OR expires_at > datetime('now'))
ORDER BY created_at DESC
''', (user_id,))
return [{
"id": row[0], "access_token": row[1], "refresh_token": row[2], "token_type": row[3],
"expires_at": row[4], "expires_in": row[5], "scope": row[6], "site_id": row[7],
"member_id": row[8], "created_at": row[9], "token_key_version": row[10],
"token_key_reference": row[11]
} for row in cursor.fetchall()]
tokens = []
for row in cursor.fetchall():
tokens.append({
"id": row[0],
"access_token": row[1],
"refresh_token": row[2],
"token_type": row[3],
"expires_at": row[4],
"expires_in": row[5],
"scope": row[6],
"site_id": row[7],
"member_id": row[8],
"created_at": row[9]
})
return tokens
except Exception as e:
logger.error(f"Error getting Wix tokens for user {user_id}: {e}")
return []
def get_user_tokens_decrypted(self, user_id: str) -> List[Dict[str, Any]]:
"""Decrypt tokens for integration managers and token refresh routines."""
decrypted = []
for token in self.get_user_tokens(user_id):
token_copy = dict(token)
token_copy["access_token"] = self.token_crypto.decrypt_token(token_copy.get("access_token"))
token_copy["refresh_token"] = self.token_crypto.decrypt_token(token_copy.get("refresh_token"))
decrypted.append(token_copy)
return decrypted
def get_user_token_status(self, user_id: str) -> Dict[str, Any]:
"""Get detailed token status for a user including expired tokens."""
try:
# Ensure database tables exist to prevent 'no such table' errors
self._init_db(user_id)
db_path = self._get_db_path(user_id)
if not os.path.exists(db_path):
return {"has_tokens": False, "has_active_tokens": False, "has_expired_tokens": False,
"active_tokens": [], "expired_tokens": [], "total_tokens": 0, "last_token_date": None}
return {
"has_tokens": False,
"has_active_tokens": False,
"has_expired_tokens": False,
"active_tokens": [],
"expired_tokens": [],
"total_tokens": 0,
"last_token_date": None
}
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
# Get all tokens (active and expired)
cursor.execute('''
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id,
created_at, is_active, token_key_version, token_key_reference
SELECT id, access_token, refresh_token, token_type, expires_at, expires_in, scope, site_id, member_id, created_at, is_active
FROM wix_oauth_tokens
WHERE user_id = ?
ORDER BY created_at DESC
''', (user_id,))
all_tokens, active_tokens, expired_tokens = [], [], []
all_tokens = []
active_tokens = []
expired_tokens = []
for row in cursor.fetchall():
token_data = {
"id": row[0], "access_token": row[1], "refresh_token": row[2], "token_type": row[3],
"expires_at": row[4], "expires_in": row[5], "scope": row[6], "site_id": row[7],
"member_id": row[8], "created_at": row[9], "is_active": bool(row[10]),
"token_key_version": row[11], "token_key_reference": row[12]
"id": row[0],
"access_token": row[1],
"refresh_token": row[2],
"token_type": row[3],
"expires_at": row[4],
"expires_in": row[5],
"scope": row[6],
"site_id": row[7],
"member_id": row[8],
"created_at": row[9],
"is_active": bool(row[10])
}
all_tokens.append(token_data)
# Determine expiry using robust parsing and is_active flag
is_active_flag = bool(row[10])
not_expired = False
try:
expires_at_val = row[4]
if expires_at_val:
# First try Python parsing
try:
dt = datetime.fromisoformat(expires_at_val) if isinstance(expires_at_val, str) else expires_at_val
not_expired = dt > datetime.now()
except Exception:
# Fallback to SQLite comparison
cursor.execute("SELECT datetime('now') < ?", (expires_at_val,))
not_expired = cursor.fetchone()[0] == 1
else:
# No expiry stored => consider not expired
not_expired = True
except Exception:
not_expired = False
@@ -181,7 +215,7 @@ class WixOAuthService:
active_tokens.append(token_data)
else:
expired_tokens.append(token_data)
return {
"has_tokens": len(all_tokens) > 0,
"has_active_tokens": len(active_tokens) > 0,
@@ -191,101 +225,81 @@ class WixOAuthService:
"total_tokens": len(all_tokens),
"last_token_date": all_tokens[0]["created_at"] if all_tokens else None
}
except Exception as e:
logger.error(f"Error getting Wix token status for user {user_id}: {e}")
return {"has_tokens": False, "has_active_tokens": False, "has_expired_tokens": False,
"active_tokens": [], "expired_tokens": [], "total_tokens": 0, "last_token_date": None, "error": str(e)}
def update_tokens(self, user_id: str, access_token: str, refresh_token: Optional[str] = None,
expires_in: Optional[int] = None) -> bool:
return {
"has_tokens": False,
"has_active_tokens": False,
"has_expired_tokens": False,
"active_tokens": [],
"expired_tokens": [],
"total_tokens": 0,
"last_token_date": None,
"error": str(e)
}
def update_tokens(
self,
user_id: str,
access_token: str,
refresh_token: Optional[str] = None,
expires_in: Optional[int] = None
) -> bool:
"""Update tokens for a user (e.g., after refresh)."""
try:
# Ensure DB initialized for this user
self._init_db(user_id)
db_path = self._get_db_path(user_id)
expires_at = datetime.now() + timedelta(seconds=expires_in) if expires_in else None
encrypted_access_token = self.token_crypto.encrypt_token(access_token)
encrypted_refresh_token = self.token_crypto.encrypt_token(refresh_token) if refresh_token else None
expires_at = None
if expires_in:
expires_at = datetime.now() + timedelta(seconds=expires_in)
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
if refresh_token:
cursor.execute('''
UPDATE wix_oauth_tokens
SET access_token = ?, refresh_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now'), token_key_version = ?, token_key_reference = ?
WHERE user_id = ? AND (refresh_token = ? OR refresh_token = ?)
''', (encrypted_access_token, encrypted_refresh_token, expires_at, expires_in,
self.token_crypto.key_version, self.token_crypto.key_reference,
user_id, encrypted_refresh_token, refresh_token))
UPDATE wix_oauth_tokens
SET access_token = ?, refresh_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now')
WHERE user_id = ? AND refresh_token = ?
''', (access_token, refresh_token, expires_at, expires_in, user_id, refresh_token))
else:
cursor.execute('''
UPDATE wix_oauth_tokens
SET access_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now'), token_key_version = ?, token_key_reference = ?
UPDATE wix_oauth_tokens
SET access_token = ?, expires_at = ?, expires_in = ?,
is_active = TRUE, updated_at = datetime('now')
WHERE user_id = ? AND id = (SELECT id FROM wix_oauth_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1)
''', (encrypted_access_token, expires_at, expires_in,
self.token_crypto.key_version, self.token_crypto.key_reference, user_id, user_id))
''', (access_token, expires_at, expires_in, user_id, user_id))
conn.commit()
logger.info(f"Wix OAuth: Encrypted tokens updated for user {user_id}")
logger.info(f"Wix OAuth: Tokens updated for user {user_id}")
return True
except Exception as e:
logger.error(f"Error updating Wix tokens for user {user_id}: {e}")
return False
def rotate_token_encryption(self, user_id: str, batch_size: int = 100) -> Dict[str, int]:
"""Re-encrypt existing token rows in batches for key rotation."""
self._init_db(user_id)
db_path = self._get_db_path(user_id)
rotated, skipped, last_id = 0, 0, 0
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
while True:
cursor.execute('''
SELECT id, access_token, refresh_token
FROM wix_oauth_tokens
WHERE user_id = ? AND id > ?
ORDER BY id ASC
LIMIT ?
''', (user_id, last_id, batch_size))
rows = cursor.fetchall()
if not rows:
break
for row_id, enc_access, enc_refresh in rows:
last_id = row_id
try:
plain_access = self.token_crypto.decrypt_token(enc_access)
plain_refresh = self.token_crypto.decrypt_token(enc_refresh) if enc_refresh else None
except Exception:
skipped += 1
continue
new_access, new_refresh = self.token_crypto.encrypt_pair(plain_access, plain_refresh)
cursor.execute('''
UPDATE wix_oauth_tokens
SET access_token = ?, refresh_token = ?, token_key_version = ?, token_key_reference = ?, updated_at = datetime('now')
WHERE id = ?
''', (new_access, new_refresh, self.token_crypto.key_version, self.token_crypto.key_reference, row_id))
rotated += 1
conn.commit()
logger.info(f"Wix OAuth: Encryption rotation complete for user {user_id}; rotated={rotated}, skipped={skipped}")
return {"rotated": rotated, "skipped": skipped}
def revoke_token(self, user_id: str, token_id: int) -> bool:
"""Revoke a Wix OAuth token."""
try:
db_path = self._get_db_path(user_id)
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE wix_oauth_tokens
UPDATE wix_oauth_tokens
SET is_active = FALSE, updated_at = datetime('now')
WHERE user_id = ? AND id = ?
''', (user_id, token_id))
conn.commit()
if cursor.rowcount > 0:
logger.info(f"Wix token {token_id} revoked for user {user_id}")
return True
return False
except Exception as e:
logger.error(f"Error revoking Wix token: {e}")
return False

View File

@@ -26,7 +26,10 @@ from .executors.advertools_executor import AdvertoolsExecutor
from .executors.sif_indexing_executor import SIFIndexingExecutor
from .executors.market_trends_executor import MarketTrendsExecutor
from .utils.task_loader import load_due_monitoring_tasks
from .utils.oauth_token_task_loader import load_due_oauth_token_monitoring_tasks
from .utils.oauth_token_task_loader import (
load_due_oauth_token_monitoring_tasks,
load_near_expiry_oauth_token_tasks
)
from .utils.website_analysis_task_loader import load_due_website_analysis_tasks
from .utils.onboarding_full_website_analysis_task_loader import load_due_onboarding_full_website_analysis_tasks
from .utils.deep_competitor_analysis_task_loader import load_due_deep_competitor_analysis_tasks
@@ -70,6 +73,11 @@ def get_scheduler() -> TaskScheduler:
oauth_token_executor,
load_due_oauth_token_monitoring_tasks
)
_scheduler_instance.register_executor(
'oauth_token_refresh',
oauth_token_executor,
load_near_expiry_oauth_token_tasks
)
# Register website analysis executor
website_analysis_executor = WebsiteAnalysisExecutor()

View File

@@ -42,6 +42,8 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
self.exception_handler = SchedulerExceptionHandler()
# Expiration warning window (7 days before expiration)
self.expiration_warning_days = 7
self.max_refresh_retries = 3
self.base_retry_backoff_minutes = 15
async def execute_task(self, task: OAuthTokenMonitoringTask, db: Session) -> TaskExecutionResult:
"""
@@ -93,6 +95,10 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
task.last_success = datetime.utcnow()
task.status = 'active'
task.failure_reason = None
task.terminal_failure_reason = None
task.channel_status = 'connected'
task.refresh_attempts = 0
task.next_retry_at = None
# Reset failure tracking on success
task.consecutive_failures = 0
task.failure_pattern = None
@@ -112,6 +118,7 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
task.last_failure = datetime.utcnow()
task.failure_reason = result.error_message
task.refresh_attempts = (task.refresh_attempts or 0) + 1
if pattern and pattern.should_cool_off:
# Mark task for human intervention
@@ -126,6 +133,9 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
}
# Clear next_check - task won't run automatically
task.next_check = None
task.next_retry_at = None
task.channel_status = "disconnected"
task.terminal_failure_reason = result.error_message
self.logger.warning(
f"Task {task.id} marked for human intervention: "
@@ -133,10 +143,17 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
f"reason: {pattern.failure_reason.value}"
)
else:
# Normal failure handling
task.status = 'failed'
task.consecutive_failures = (task.consecutive_failures or 0) + 1
# Do NOT update next_check - wait for manual trigger
if task.refresh_attempts >= self.max_refresh_retries:
task.status = 'failed'
task.channel_status = 'disconnected'
task.terminal_failure_reason = result.error_message
task.next_retry_at = None
else:
task.status = 'degraded'
task.channel_status = 'degraded'
delay_minutes = self.base_retry_backoff_minutes * (2 ** (task.refresh_attempts - 1))
task.next_retry_at = datetime.utcnow() + timedelta(minutes=delay_minutes)
self.logger.warning(
f"OAuth token refresh failed for user {user_id}, platform {platform}. "
@@ -144,7 +161,7 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
)
# Create UsageAlert notification for the user
self._create_failure_alert(user_id, platform, result.error_message, result.result_data, db)
self._create_failure_alert(user_id, platform, result.error_message, result.result_data, db, task)
task.updated_at = datetime.utcnow()
db.commit()
@@ -193,12 +210,14 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
task.last_failure = datetime.utcnow()
task.failure_reason = str(e)
task.status = 'failed'
task.channel_status = 'disconnected'
task.terminal_failure_reason = str(e)
task.last_check = datetime.utcnow()
task.updated_at = datetime.utcnow()
# Do NOT update next_check - wait for manual trigger
task.next_retry_at = None
# Create UsageAlert notification for the user
self._create_failure_alert(user_id, task.platform, str(e), None, db)
self._create_failure_alert(user_id, task.platform, str(e), None, db, task)
db.commit()
except Exception as commit_error:
@@ -651,7 +670,8 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
platform: str,
error_message: str,
result_data: Optional[Dict[str, Any]],
db: Session
db: Session,
task: Optional[OAuthTokenMonitoringTask] = None
):
"""
Create a UsageAlert notification when OAuth token refresh fails.
@@ -723,6 +743,20 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
# Get current billing period (YYYY-MM format)
from datetime import datetime
billing_period = datetime.utcnow().strftime("%Y-%m")
alert_payload = {
"requires_user_action": True,
"platform": platform,
"channel_status": getattr(task, "channel_status", "disconnected"),
"terminal_failure_reason": getattr(task, "terminal_failure_reason", error_message),
"next_retry_at": (
task.next_retry_at.isoformat() if task and task.next_retry_at else None
),
"refresh_attempts": getattr(task, "refresh_attempts", 0),
"max_refresh_retries": self.max_refresh_retries,
}
message = f"{message} [ALERT_PAYLOAD] {alert_payload}"
# Create UsageAlert
alert = UsageAlert(
@@ -786,4 +820,3 @@ class OAuthTokenMonitoringExecutor(TaskExecutor):
f"Defaulting to Weekly (7 days)."
)
return last_execution + timedelta(days=7)

View File

@@ -3,7 +3,7 @@ OAuth Token Monitoring Task Loader
Functions to load due OAuth token monitoring tasks from database.
"""
from datetime import datetime
from datetime import datetime, timedelta
from typing import List, Optional, Union
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
@@ -52,3 +52,34 @@ def load_due_oauth_token_monitoring_tasks(
return query.all()
def load_near_expiry_oauth_token_tasks(
db: Session,
refresh_horizon_hours: int = 24,
user_id: Optional[Union[str, int]] = None
) -> List[OAuthTokenMonitoringTask]:
"""
Load OAuth tasks that should run token refresh logic soon.
Includes:
- tasks with a scheduled retry now due (next_retry_at <= now)
- tasks whose routine check is inside the near-expiry horizon window
"""
now = datetime.utcnow()
horizon = now + timedelta(hours=max(refresh_horizon_hours, 1))
query = db.query(OAuthTokenMonitoringTask).filter(
and_(
OAuthTokenMonitoringTask.status.in_(['active', 'failed', 'degraded']),
or_(
OAuthTokenMonitoringTask.next_retry_at <= now,
OAuthTokenMonitoringTask.next_check <= horizon,
OAuthTokenMonitoringTask.next_check.is_(None)
)
)
)
if user_id is not None:
query = query.filter(OAuthTokenMonitoringTask.user_id == str(user_id))
return query.all()

View File

@@ -1,69 +0,0 @@
"""Service for encrypting/decrypting integration tokens with key version metadata."""
import base64
import hashlib
import os
from typing import Optional, Tuple
from cryptography.fernet import Fernet, InvalidToken
from loguru import logger
class TokenCryptoService:
"""Token encryption/decryption service with key version support."""
ENV_KEY = "ALWRITY_TOKEN_ENCRYPTION_KEY"
ENV_KEY_VERSION = "ALWRITY_TOKEN_KEY_VERSION"
def __init__(self):
raw_key = os.getenv(self.ENV_KEY, "")
if raw_key:
self._fernet_key = self._normalize_key(raw_key)
else:
self._fernet_key = self._derive_dev_key()
self._fernet = Fernet(self._fernet_key)
self._key_version = os.getenv(self.ENV_KEY_VERSION, "v1")
self._key_reference = self._fingerprint(self._fernet_key)
@property
def key_version(self) -> str:
return self._key_version
@property
def key_reference(self) -> str:
return self._key_reference
def encrypt_token(self, token: Optional[str]) -> Optional[str]:
if token is None:
return None
return self._fernet.encrypt(token.encode("utf-8")).decode("utf-8")
def decrypt_token(self, encrypted_token: Optional[str]) -> Optional[str]:
if encrypted_token is None:
return None
try:
return self._fernet.decrypt(encrypted_token.encode("utf-8")).decode("utf-8")
except InvalidToken:
logger.error("Token decryption failed due to invalid token/key")
raise
def encrypt_pair(self, access_token: str, refresh_token: Optional[str]) -> Tuple[str, Optional[str]]:
return self.encrypt_token(access_token), self.encrypt_token(refresh_token)
@staticmethod
def _normalize_key(raw_key: str) -> bytes:
raw_key = raw_key.strip()
if len(raw_key) == 44 and raw_key.endswith("="):
return raw_key.encode("utf-8")
digest = hashlib.sha256(raw_key.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
@staticmethod
def _derive_dev_key() -> bytes:
seed = "alwrity-local-token-key"
digest = hashlib.sha256(seed.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest)
@staticmethod
def _fingerprint(key: bytes) -> str:
return hashlib.sha256(key).hexdigest()[:16]