aboutsummaryrefslogtreecommitdiff
path: root/jb/api/magic_token.py
diff options
context:
space:
mode:
authorMax Nanis2026-09-13 19:03:55 +0000
committerMax Nanis2026-09-13 19:03:55 +0000
commit8fbe8d439b418796932aaa88297e006c714e4d13 (patch)
tree6c800476edc1e771fc570b559d483df8d59d4324 /jb/api/magic_token.py
parent12f6fee851b68e86af658dfa17e4a0daed457dd1 (diff)
parent2c94f248d2438071a918fa9a30bf114ef9aa29b4 (diff)
downloadamt-jb-8fbe8d439b418796932aaa88297e006c714e4d13.tar.gz
amt-jb-8fbe8d439b418796932aaa88297e006c714e4d13.zip
Merges pull request #3
Off of Amazon!!!
Diffstat (limited to 'jb/api/magic_token.py')
-rw-r--r--jb/api/magic_token.py96
1 files changed, 96 insertions, 0 deletions
diff --git a/jb/api/magic_token.py b/jb/api/magic_token.py
new file mode 100644
index 0000000..b60d5e9
--- /dev/null
+++ b/jb/api/magic_token.py
@@ -0,0 +1,96 @@
+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 = 10 * 60 # 10 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)