aboutsummaryrefslogtreecommitdiff
path: root/jb/models/auth.py
diff options
context:
space:
mode:
Diffstat (limited to 'jb/models/auth.py')
-rw-r--r--jb/models/auth.py110
1 files changed, 110 insertions, 0 deletions
diff --git a/jb/models/auth.py b/jb/models/auth.py
new file mode 100644
index 0000000..886a607
--- /dev/null
+++ b/jb/models/auth.py
@@ -0,0 +1,110 @@
+import hashlib
+import hmac
+from typing import Any
+
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ EmailStr,
+ Field,
+ TypeAdapter,
+ computed_field,
+ field_validator,
+ model_validator,
+)
+
+from jb.config import settings
+
+
+def email_to_product_user_id(email: str) -> str:
+ """Return a deterministic, non-reversible product user ID for an email.
+
+ The same normalized email and secret salt always produce the same ID. Keep
+ the salt private and stable; changing it changes every generated ID.
+ """
+ assert settings.magic_token_salt
+ salt_bytes = settings.magic_token_salt.get_secret_value().encode("utf-8")
+ if len(salt_bytes) < 32:
+ raise ValueError("salt must be at least 32 bytes")
+
+ if not email.isascii():
+ raise ValueError("email must contain ASCII characters only")
+
+ normalized_email = str(TypeAdapter(EmailStr).validate_python(email)).lower()
+ return hmac.new(
+ key=salt_bytes,
+ msg=normalized_email.encode("utf-8"),
+ digestmod=hashlib.sha1,
+ ).hexdigest()
+
+
+class User(BaseModel):
+ """A user that has been authenticated and exists in THL"""
+
+ email: EmailStr = Field()
+
+ display_name: str | None = Field(default=None, max_length=255)
+
+ blocked: bool = Field(default=False)
+
+ @field_validator("email", mode="before")
+ @classmethod
+ def normalize_email(cls, value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ if not value.isascii():
+ raise ValueError("email must contain ASCII characters only")
+ return value.lower()
+
+ @model_validator(mode="before")
+ @classmethod
+ def validate_product_user_id(cls, data: Any) -> Any:
+ if not isinstance(data, dict) or "product_user_id" not in data:
+ return data
+
+ provided_id = data["product_user_id"]
+ email = data.get("email")
+ if email is None:
+ return data
+
+ expected_id = email_to_product_user_id(str(email))
+ if not isinstance(provided_id, str) or not hmac.compare_digest(
+ provided_id, expected_id
+ ):
+ raise ValueError(
+ f"product_user_id {provided_id} does not match email {email}"
+ )
+
+ # The computed field is authoritative; do not retain the input value.
+ validated_data = dict(data)
+ validated_data.pop("product_user_id")
+ return validated_data
+
+ @computed_field
+ @property
+ def product_user_id(self) -> str:
+ return email_to_product_user_id(self.email)
+
+
+class AccountLogin(BaseModel):
+ # There is no practical difference between an Account "login" and "create"
+ email: EmailStr = Field()
+
+
+class MagicLinkExchangeRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ token: str = Field(min_length=1)
+
+
+class AmtAccountLink(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ email: EmailStr
+ amt_worker_id: str = Field(min_length=3, max_length=50)
+
+
+class SessionResponse(BaseModel):
+ session_token: str
+ token_type: str = "bearer"
+ expires_in: int