Add in a mechanism for email confirmation

This is some of the way there but it is still missing at least:
- An actual page to view to confirm the email
- An expiry time on the minted JWT (and other JWT issues like "sub")
This commit is contained in:
Campbell Alden 2026-08-03 00:33:12 +09:00
parent ecb98dac53
commit 782e0b0ad6
8 changed files with 133 additions and 20 deletions

View file

@ -1,5 +1,7 @@
import abc
from src.config.email import EmailAddress
from src.config.parse import assert_key_of_type, ParseError
from typing import Any
from typing import Any, TypeVar
from dataclasses import dataclass, asdict
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import jwt
@ -9,19 +11,53 @@ from src.config.auth import Auth as AuthConfig
JWT = str
T = TypeVar('T', bound='Claim')
# TODO: Do these claims need explicit exp attributes? I think the ID fields should actually be sub now that I think
# about it...
@dataclass
class Claim(abc.ABC):
@classmethod
@abc.abstractmethod
def from_dict(cls, claims: dict[str, Any]) -> T:
pass
@classmethod
@abc.abstractmethod
def from_user(cls, user: User) -> T:
pass
@dataclass
class Claims:
class UserClaims(Claim):
id: int
@classmethod
def from_user(cls, user: User) -> 'Claims':
return Claims(id=user.id)
def from_user(cls, user: User) -> 'UserClaims':
return UserClaims(id=user.id)
@classmethod
def from_dict(cls, claims: dict[str, Any]) -> 'Claims':
def from_dict(cls, claims: dict[str, Any]) -> 'UserClaims':
assert_key_of_type(claims, 'id', int)
return Claims(id=claims['id'])
return UserClaims(id=claims['id'])
@dataclass
class EmailConfirmationClaim(Claim):
id: int
email: EmailAddress
@classmethod
def from_user(cls, user: User) -> 'EmailConfirmationClaim':
return EmailConfirmationClaim(id=user.id, email=user.email)
@classmethod
def from_dict(cls, claims: dict[str, Any]) -> 'EmailConfirmationClaim':
assert_key_of_type(claims, 'id', int)
assert_key_of_type(claims, 'email', str)
return EmailConfirmationClaim(id=claims['id'], email=claims['email'])
class AuthService:
@ -29,17 +65,17 @@ class AuthService:
self._private_key = Ed25519PrivateKey.from_private_bytes(config.ed25519_private_key.expose_secret())
self._public_key = self._private_key.public_key()
def mint_jwt(self, user: User) -> JWT:
claims = Claims.from_user(user)
def mint_claim_from_user(self, claim: type[Claim], user: User) -> JWT:
claims = claim.from_user(user)
return jwt.encode(asdict(claims), self._private_key, algorithm='EdDSA')
def validate_token(self, token: JWT) -> Claims | None:
def validate_token[T: Claim](self, claim: type[T], token: JWT) -> T | None:
try:
claims = jwt.decode(token, key=self._public_key, algorithms=['EdDSA'])
except jwt.InvalidTokenError:
return None
try:
return Claims.from_dict(claims)
return claim.from_dict(claims)
except ParseError:
return None