From 387e2fa4902d0c7f9e238c9c46e0b5389d49e58d Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Sun, 2 Aug 2026 22:50:05 +0900 Subject: [PATCH] Add an auth service for minting and decoding JWTs --- config.example.json | 3 +++ derivation.nix | 2 +- shell.nix | 2 ++ src/config/__init__.py | 13 +++++++++--- src/config/auth.py | 16 +++++++++++++++ src/services/auth.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/config/auth.py create mode 100644 src/services/auth.py diff --git a/config.example.json b/config.example.json index a994526..b97e6a3 100644 --- a/config.example.json +++ b/config.example.json @@ -11,5 +11,8 @@ "database": { "url": "sqlite://", "echo": false + }, + "auth": { + "ed25519_private_key": "A base64 encoded EdDSA Private Key" } } diff --git a/derivation.nix b/derivation.nix index e5cc219..639f015 100644 --- a/derivation.nix +++ b/derivation.nix @@ -3,7 +3,7 @@ with python313Packages; buildPythonApplication { pname = "cereal"; version = "0.0.1"; - propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi]; + propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography]; src = ./.; pyproject = true; build-system = [setuptools]; diff --git a/shell.nix b/shell.nix index 2a39902..50007eb 100644 --- a/shell.nix +++ b/shell.nix @@ -6,6 +6,8 @@ let waitress sqlalchemy argon2-cffi + pyjwt + cryptography ]); in with pkgs; diff --git a/src/config/__init__.py b/src/config/__init__.py index 32b37b3..dab2591 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -4,6 +4,7 @@ from typing import Any from .logging import Logging from .email import Email +from .auth import Auth from .database import Database from .parse import assert_key_of_type, parse_nested_config @@ -15,19 +16,25 @@ class Config: logging: Logging email: Email database: Database + auth: Auth @classmethod def from_dict(cls, config: dict[str, Any]) -> 'Config': - assert_key_of_type(config, 'logging', dict) - assert_key_of_type(config, 'email', dict) assert_key_of_type(config, 'host', str) assert_key_of_type(config, 'port', int) + log_config = parse_nested_config(config, 'logging', Logging.from_dict) email_config = parse_nested_config(config, 'email', Email.from_dict) db_config = parse_nested_config(config, 'database', Database.from_dict) + auth_config = parse_nested_config(config, 'auth', Auth.from_dict) return Config( - host=config['host'], port=config['port'], email=email_config, logging=log_config, database=db_config + host=config['host'], + port=config['port'], + email=email_config, + logging=log_config, + database=db_config, + auth=auth_config, ) diff --git a/src/config/auth.py b/src/config/auth.py new file mode 100644 index 0000000..dbc5f33 --- /dev/null +++ b/src/config/auth.py @@ -0,0 +1,16 @@ +from base64 import b64decode +from typing import Any +from src.config.parse import assert_key_of_type +from src.utils.secret import SecretBox +from dataclasses import dataclass + + +@dataclass +class Auth: + ed25519_private_key: SecretBox[bytes] + + @classmethod + def from_dict(cls, config: dict[str, Any]) -> 'Auth': + assert_key_of_type(config, 'ed25519_private_key', str) + private_key = b64decode(config['ed25519_private_key']) + return Auth(ed25519_private_key=SecretBox(private_key)) diff --git a/src/services/auth.py b/src/services/auth.py new file mode 100644 index 0000000..ec38c2c --- /dev/null +++ b/src/services/auth.py @@ -0,0 +1,45 @@ +from src.config.parse import assert_key_of_type, ParseError +from typing import Any +from dataclasses import dataclass, asdict +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +import jwt + +from src.services.users import User +from src.config.auth import Auth as AuthConfig + +JWT = str + + +@dataclass +class Claims: + id: int + + @classmethod + def from_user(cls, user: User) -> 'Claims': + return Claims(id=user.id) + + @classmethod + def from_dict(cls, claims: dict[str, Any]) -> 'Claims': + assert_key_of_type(claims, 'id', int) + return Claims(id=claims['id']) + + +class AuthService: + def __init__(self, config: AuthConfig): + 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) + return jwt.encode(asdict(claims), self._private_key, algorithm='EdDSA') + + def validate_token(self, token: JWT) -> Claims | None: + try: + claims = jwt.decode(token, key=self._public_key, algorithms=['EdDSA']) + except jwt.InvalidTokenError: + return None + + try: + return Claims.from_dict(claims) + except ParseError: + return None