import hashlib import secrets from fastapi import HTTPException, status from generalresearch.redis_helper import RedisConfig from jb.decorators import get_redis_config from jb.models.auth import AmtAccountLink MAGIC_TOKEN_PREFIX = "auth:magic:" AMT_ACCOUNT_LINK_TOKEN_PREFIX = "auth:amt-account-link:" MAGIC_TOKEN_TTL: int = 5 * 60 # 5 minutes, in seconds def redis_token_key(token: str, prefix: str = MAGIC_TOKEN_PREFIX) -> str: # Redis never contains a usable credential, even if its keys are exposed. digest = hashlib.sha256(token.encode("utf-8")).hexdigest() return f"{prefix}{digest}" def create_magic_token(user_email: str, redis_config: RedisConfig | None = None) -> str: """Create a short-lived, single-use token for a user. The raw token can then be sent by email. """ if not user_email or not user_email.strip(): raise ValueError("user_email must not be empty") token = secrets.token_urlsafe(32) if redis_config is None: redis_config = get_redis_config() redis_client = redis_config.create_redis_client() redis_client.set( redis_token_key(token), user_email, ex=MAGIC_TOKEN_TTL, ) return token def consume_magic_token(token: str, redis_config: RedisConfig | None = None) -> str: if redis_config is None: redis_config = get_redis_config() redis_client = redis_config.create_redis_client() user_email = redis_client.getdel(redis_token_key(token)) if user_email is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired magic token", ) return str(user_email) def create_amt_account_link_token( email: str, amt_worker_id: str, redis_config: RedisConfig | None = None ) -> str: """Bind an email and AMT worker ID to an opaque, short-lived token.""" if redis_config is None: redis_config = get_redis_config() redis_client = redis_config.create_redis_client() data = AmtAccountLink( email=email, amt_worker_id=amt_worker_id, ) token = secrets.token_urlsafe(32) redis_client.set( redis_token_key(token, AMT_ACCOUNT_LINK_TOKEN_PREFIX), data.model_dump_json(), ex=MAGIC_TOKEN_TTL, ) return token def consume_amt_account_link_token( token: str, redis_config: RedisConfig | None = None ) -> AmtAccountLink: """Atomically consume and validate an AMT account-link token.""" if redis_config is None: redis_config = get_redis_config() redis_client = redis_config.create_redis_client() raw_data = redis_client.getdel( redis_token_key(token, AMT_ACCOUNT_LINK_TOKEN_PREFIX) ) if raw_data is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired account-link token", ) return AmtAccountLink.model_validate_json(raw_data)