austial-auth 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.db
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .DS_Store
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.5
2
+ Name: austial-auth
3
+ Version: 0.2.0
4
+ Summary: Authentication primitives -- Argon2 hasher, JWT strategy, TOTP MFA, AuthGuard, refresh-token store -- mirrors @nestjs/passport + @nestjs/jwt.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: argon2-cffi>=23.1.0
7
+ Requires-Dist: austial-common
8
+ Requires-Dist: austial-core
9
+ Requires-Dist: pyjwt>=2.9.0
10
+ Requires-Dist: pyotp>=2.9.0
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "austial-auth"
3
+ dynamic = ["version"]
4
+ description = "Authentication primitives -- Argon2 hasher, JWT strategy, TOTP MFA, AuthGuard, refresh-token store -- mirrors @nestjs/passport + @nestjs/jwt."
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "austial-common",
8
+ "austial-core",
9
+ "argon2-cffi>=23.1.0",
10
+ "pyjwt>=2.9.0",
11
+ "pyotp>=2.9.0",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["hatchling", "hatch-vcs"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/austial"]
20
+
21
+ [tool.uv.sources]
22
+ austial-common = { workspace = true }
23
+ austial-core = { workspace = true }
24
+
25
+ [tool.hatch.version]
26
+ source = "vcs"
27
+
28
+ [tool.hatch.version.raw-options]
29
+ root = "../.."
@@ -0,0 +1,57 @@
1
+ """``austial.auth`` -- authentication primitives.
2
+
3
+ Ships an ``AuthModule.for_root(...)`` dynamic module, an ``Argon2Hasher`` (OWASP-recommended
4
+ password hashing), a ``JwtStrategy`` (short-lived access + opaque rotating refresh tokens),
5
+ a ``TotpStrategy`` (RFC 6238 MFA), an ``AuthGuard`` (bearer-token verification), the
6
+ ``@Public`` decorator to skip that guard, and a ``CurrentUser`` param decorator to inject
7
+ the authenticated principal into a handler. The product supplies the user model and the
8
+ persistent refresh-token store (an in-memory default is provided for tests).
9
+
10
+ Design mirrors ``@nestjs/passport`` + ``@nestjs/jwt``: the framework provides *strategies*
11
+ and *guards*; product wires them together against its own user entity. Everything is
12
+ side-effect-free and testable in isolation.
13
+ """
14
+
15
+ from austial.auth.argon2_hasher import Argon2Hasher, PasswordHasher, PasswordVerificationError
16
+ from austial.auth.auth_guard import AuthGuard, AuthPrincipal
17
+ from austial.auth.auth_module import AuthModule
18
+ from austial.auth.auth_service import AuthService
19
+ from austial.auth.config import AuthConfig
20
+ from austial.auth.decorators import IS_PUBLIC_METADATA, CurrentUser, Public
21
+ from austial.auth.errors import (
22
+ AuthenticationError,
23
+ InvalidTokenError,
24
+ MfaRequiredError,
25
+ TokenExpiredError,
26
+ )
27
+ from austial.auth.jwt_strategy import JwtStrategy, TokenPayload
28
+ from austial.auth.refresh_token_store import (
29
+ InMemoryRefreshTokenStore,
30
+ RefreshTokenRecord,
31
+ RefreshTokenStore,
32
+ )
33
+ from austial.auth.totp_strategy import TotpStrategy
34
+
35
+ __all__ = [
36
+ "Argon2Hasher",
37
+ "AuthConfig",
38
+ "AuthenticationError",
39
+ "AuthGuard",
40
+ "AuthModule",
41
+ "AuthPrincipal",
42
+ "AuthService",
43
+ "CurrentUser",
44
+ "InMemoryRefreshTokenStore",
45
+ "InvalidTokenError",
46
+ "IS_PUBLIC_METADATA",
47
+ "JwtStrategy",
48
+ "MfaRequiredError",
49
+ "PasswordHasher",
50
+ "PasswordVerificationError",
51
+ "Public",
52
+ "RefreshTokenRecord",
53
+ "RefreshTokenStore",
54
+ "TokenExpiredError",
55
+ "TokenPayload",
56
+ "TotpStrategy",
57
+ ]
@@ -0,0 +1,73 @@
1
+ """``Argon2Hasher`` -- OWASP ASVS L2 password hashing.
2
+
3
+ Argon2id with defaults calibrated to OWASP's 2024 recommendations for interactive
4
+ authentication (>=19 MiB memory, >=2 iterations, 1 lane). The hasher is stateless and
5
+ injectable; callers use ``hash()`` at registration/password-change and ``verify()`` at
6
+ login, and check ``needs_rehash()`` to opportunistically upgrade older hashes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol, runtime_checkable
12
+
13
+ from argon2 import PasswordHasher as _Argon2
14
+ from argon2.exceptions import InvalidHashError, VerifyMismatchError
15
+
16
+ from austial.common.decorators.injectable import Injectable
17
+
18
+
19
+ class PasswordVerificationError(Exception):
20
+ """Raised when the supplied password does not match the stored hash."""
21
+
22
+
23
+ @runtime_checkable
24
+ class PasswordHasher(Protocol):
25
+ """Injectable interface: any hasher that implements these three methods
26
+ is a drop-in replacement for ``Argon2Hasher`` (useful in tests)."""
27
+
28
+ def hash(self, password: str) -> str: ...
29
+
30
+ def verify(self, password: str, encoded: str) -> None: ...
31
+
32
+ def needs_rehash(self, encoded: str) -> bool: ...
33
+
34
+
35
+ @Injectable()
36
+ class Argon2Hasher:
37
+ """Argon2id password hasher. Defaults tuned to OWASP's 2024 interactive-login profile."""
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ time_cost: int = 2,
43
+ memory_cost: int = 19 * 1024, # 19 MiB, per OWASP 2024 minimum
44
+ parallelism: int = 1,
45
+ hash_len: int = 32,
46
+ salt_len: int = 16,
47
+ ) -> None:
48
+ self._hasher = _Argon2(
49
+ time_cost=time_cost,
50
+ memory_cost=memory_cost,
51
+ parallelism=parallelism,
52
+ hash_len=hash_len,
53
+ salt_len=salt_len,
54
+ )
55
+
56
+ def hash(self, password: str) -> str:
57
+ """Return a PHC-string-encoded Argon2id hash of ``password``."""
58
+ return self._hasher.hash(password)
59
+
60
+ def verify(self, password: str, encoded: str) -> None:
61
+ """Verify ``password`` against ``encoded``; raise ``PasswordVerificationError``
62
+ on any mismatch or malformed hash. Callers should treat all failures identically
63
+ to avoid leaking why authentication failed."""
64
+ try:
65
+ self._hasher.verify(encoded, password)
66
+ except (VerifyMismatchError, InvalidHashError) as exc:
67
+ raise PasswordVerificationError("password verification failed") from exc
68
+
69
+ def needs_rehash(self, encoded: str) -> bool:
70
+ """True if ``encoded`` was produced with older parameters and should be rehashed
71
+ with the current profile on next successful login. Callers should silently
72
+ rehash + persist to keep pace with parameter upgrades."""
73
+ return self._hasher.check_needs_rehash(encoded)
@@ -0,0 +1,67 @@
1
+ """``AuthGuard`` -- bearer-token verification for every request.
2
+
3
+ Runs before the handler (see the request pipeline in ``austial.core``). Skips routes
4
+ marked ``@Public``. On success, populates ``request.state.user`` with an
5
+ ``AuthPrincipal`` so ``CurrentUser`` can retrieve it. On failure, raises
6
+ ``UnauthorizedException`` (401).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from austial.auth.decorators import is_public
15
+ from austial.auth.errors import AuthenticationError, InvalidTokenError
16
+ from austial.auth.jwt_strategy import JwtStrategy, TokenPayload
17
+ from austial.common.decorators.injectable import Injectable
18
+ from austial.common.guards.base import CanActivate, ExecutionContext
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class AuthPrincipal:
23
+ """The authenticated caller. ``subject`` is the user id; ``claims`` is the JWT
24
+ payload minus system claims. Accessible via ``CurrentUser()`` or
25
+ ``request.state.user``."""
26
+
27
+ subject: str
28
+ claims: dict[str, Any]
29
+ token_id: str | None = None
30
+
31
+
32
+ @Injectable()
33
+ class AuthGuard(CanActivate):
34
+ """Verifies a ``Bearer`` token from the ``Authorization`` header via ``JwtStrategy``.
35
+
36
+ Product code can subclass this to enforce additional claims (tenant, roles, session
37
+ freshness) after the base verification succeeds -- override ``after_verify`` and
38
+ raise ``UnauthorizedException`` to reject."""
39
+
40
+ def __init__(self, jwt_strategy: JwtStrategy) -> None:
41
+ self._jwt = jwt_strategy
42
+
43
+ async def can_activate(self, context: ExecutionContext) -> bool:
44
+ handler = context.get_handler()
45
+ controller = context.get_class()
46
+ if is_public(handler) or is_public(controller):
47
+ return True
48
+
49
+ request = context.get_request()
50
+ header = request.headers.get("Authorization", "")
51
+ if not header or not header.lower().startswith("bearer "):
52
+ raise AuthenticationError("missing bearer token")
53
+
54
+ token = header.split(" ", 1)[1].strip()
55
+ if not token:
56
+ raise InvalidTokenError("empty bearer token")
57
+
58
+ payload = self._jwt.verify(token)
59
+ principal = AuthPrincipal(subject=payload.subject, claims=payload.claims, token_id=payload.token_id)
60
+ await self.after_verify(request, payload, principal)
61
+ request.state.user = principal
62
+ return True
63
+
64
+ async def after_verify(self, request: Any, payload: TokenPayload, principal: AuthPrincipal) -> None:
65
+ """Hook for subclasses to add checks (e.g. session revocation) after the
66
+ base signature/expiry checks pass. Raise an exception to reject."""
67
+ # base implementation intentionally does nothing
@@ -0,0 +1,89 @@
1
+ """``AuthModule.for_root(...)`` -- wires ``JwtStrategy``, ``TotpStrategy``,
2
+ ``Argon2Hasher``, ``AuthGuard`` and (optionally) an in-memory refresh-token store
3
+ into the DI container.
4
+
5
+ Product code overrides the refresh-token store with a persistent implementation
6
+ by passing ``refresh_token_store=MyPersistentStore``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from austial.auth.argon2_hasher import Argon2Hasher, PasswordHasher
14
+ from austial.auth.auth_guard import AuthGuard
15
+ from austial.auth.auth_service import AuthService
16
+ from austial.auth.config import AuthConfig
17
+ from austial.auth.jwt_strategy import JwtStrategy
18
+ from austial.auth.refresh_token_store import InMemoryRefreshTokenStore, RefreshTokenStore
19
+ from austial.auth.totp_strategy import TotpStrategy
20
+ from austial.common.decorators.modules import Module
21
+ from austial.core.dynamic_module import DynamicModule
22
+
23
+
24
+ @Module()
25
+ class AuthModule:
26
+ """Import via ``AuthModule.for_root(config, refresh_token_store=...)`` in ``AppModule``'s
27
+ ``imports=[...]``. Registered ``@Global`` by default so any module can inject
28
+ ``JwtStrategy``, ``TotpStrategy``, ``PasswordHasher``, ``AuthService`` and ``AuthGuard``."""
29
+
30
+ @staticmethod
31
+ def for_root(
32
+ config: AuthConfig,
33
+ *,
34
+ refresh_token_store: type | RefreshTokenStore | None = None,
35
+ password_hasher: type | PasswordHasher | None = None,
36
+ is_global: bool = True,
37
+ ) -> DynamicModule:
38
+ jwt = JwtStrategy(
39
+ secret=config.jwt_secret,
40
+ issuer=config.issuer,
41
+ algorithm=config.algorithm,
42
+ access_ttl=config.access_ttl,
43
+ )
44
+ totp = TotpStrategy(
45
+ issuer=config.issuer,
46
+ digits=config.totp_digits,
47
+ window=config.totp_window,
48
+ )
49
+ hasher: PasswordHasher = (
50
+ Argon2Hasher() if password_hasher is None or isinstance(password_hasher, type) else password_hasher
51
+ )
52
+ # A class (not an instance) is registered with ``useClass`` so the container can inject its own
53
+ # dependencies -- a persistent store needs repositories, which do not exist before DI runs.
54
+ store_provider: Any = (
55
+ {"provide": RefreshTokenStore, "useClass": refresh_token_store}
56
+ if isinstance(refresh_token_store, type)
57
+ else {"provide": RefreshTokenStore, "useValue": refresh_token_store or InMemoryRefreshTokenStore()}
58
+ )
59
+ hasher_provider: Any = (
60
+ {"provide": PasswordHasher, "useClass": password_hasher}
61
+ if isinstance(password_hasher, type)
62
+ else {"provide": PasswordHasher, "useValue": hasher}
63
+ )
64
+
65
+ providers: list[Any] = [
66
+ {"provide": JwtStrategy, "useValue": jwt},
67
+ {"provide": TotpStrategy, "useValue": totp},
68
+ hasher_provider,
69
+ {"provide": Argon2Hasher, "useValue": hasher if isinstance(hasher, Argon2Hasher) else Argon2Hasher()},
70
+ store_provider,
71
+ {"provide": AuthConfig, "useValue": config},
72
+ AuthService,
73
+ AuthGuard,
74
+ ]
75
+
76
+ return DynamicModule(
77
+ module=AuthModule,
78
+ providers=providers,
79
+ exports=[
80
+ JwtStrategy,
81
+ TotpStrategy,
82
+ PasswordHasher,
83
+ Argon2Hasher,
84
+ RefreshTokenStore,
85
+ AuthConfig,
86
+ AuthService,
87
+ AuthGuard,
88
+ ],
89
+ )
@@ -0,0 +1,112 @@
1
+ """``AuthService`` -- orchestrates login/refresh/logout on top of the strategies.
2
+
3
+ The service is agnostic to the user model: the product calls
4
+ ``verify_password(...)`` with the stored hash it already loaded from its user
5
+ table, and ``issue_tokens(subject, claims)`` to mint the access + refresh pair.
6
+ This keeps the framework decoupled from persistence.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ from collections.abc import Awaitable, Callable
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from austial.auth.argon2_hasher import PasswordHasher, PasswordVerificationError
17
+ from austial.auth.config import AuthConfig
18
+ from austial.auth.errors import AuthenticationError, MfaRequiredError
19
+ from austial.auth.jwt_strategy import JwtStrategy
20
+ from austial.auth.refresh_token_store import RefreshTokenRecord, RefreshTokenStore
21
+ from austial.auth.totp_strategy import TotpStrategy
22
+ from austial.common.decorators.injectable import Injectable
23
+
24
+ #: ``claims`` for :meth:`AuthService.refresh` may be a callable that receives the *rotated* token's record
25
+ #: and returns the access-token claims (sync or async). Needed because the claims usually depend on what
26
+ #: the record points at (its session, the subject's current tenant/roles), which is only known once the
27
+ #: refresh token has been resolved.
28
+ ClaimsProvider = Callable[[RefreshTokenRecord], dict[str, Any] | Awaitable[dict[str, Any]]]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class TokenPair:
33
+ """Returned to the caller on login/refresh."""
34
+
35
+ access_token: str
36
+ refresh_token: str
37
+ expires_in_seconds: int
38
+
39
+
40
+ @Injectable()
41
+ class AuthService:
42
+ def __init__(
43
+ self,
44
+ jwt: JwtStrategy,
45
+ totp: TotpStrategy,
46
+ hasher: PasswordHasher,
47
+ refresh_store: RefreshTokenStore,
48
+ config: AuthConfig,
49
+ ) -> None:
50
+ self._jwt = jwt
51
+ self._totp = totp
52
+ self._hasher = hasher
53
+ self._refresh_store = refresh_store
54
+ self._config = config
55
+
56
+ def verify_password(self, password: str, encoded: str) -> None:
57
+ """Raise ``AuthenticationError`` on any mismatch. Never leak which failure."""
58
+ try:
59
+ self._hasher.verify(password, encoded)
60
+ except PasswordVerificationError as exc:
61
+ raise AuthenticationError("invalid credentials") from exc
62
+
63
+ def require_mfa(self, secret: str, code: str | None) -> None:
64
+ """Raise ``MfaRequiredError`` if MFA is enrolled and ``code`` is missing or invalid."""
65
+ if secret and not (code and self._totp.verify(secret, code)):
66
+ raise MfaRequiredError()
67
+
68
+ async def issue_tokens(
69
+ self, subject: str, *, claims: dict[str, Any] | None = None, session_id: str | None = None
70
+ ) -> TokenPair:
71
+ access = self._jwt.sign(subject, claims=claims)
72
+ opaque, _ = await self._refresh_store.issue(subject, ttl=self._config.refresh_ttl, session_id=session_id)
73
+ return TokenPair(
74
+ access_token=access,
75
+ refresh_token=opaque,
76
+ expires_in_seconds=int(self._config.access_ttl.total_seconds()),
77
+ )
78
+
79
+ async def refresh(
80
+ self, opaque_refresh_token: str, *, claims: dict[str, Any] | ClaimsProvider | None = None
81
+ ) -> TokenPair:
82
+ """Rotate the refresh token and mint a fresh access token.
83
+
84
+ ``claims`` is either a fixed dict or a ``ClaimsProvider`` called with the rotated token's
85
+ ``RefreshTokenRecord`` -- use the latter to derive the claims from the record's ``session_id`` /
86
+ ``subject`` (an exception raised by the provider, e.g. ``AuthenticationError`` for a
87
+ deactivated account, propagates to the caller)."""
88
+ try:
89
+ new_opaque, record = await self._refresh_store.rotate(opaque_refresh_token, ttl=self._config.refresh_ttl)
90
+ except LookupError as exc:
91
+ raise AuthenticationError("invalid refresh token") from exc
92
+
93
+ resolved: dict[str, Any] | None
94
+ if callable(claims):
95
+ produced = claims(record)
96
+ resolved = await produced if inspect.isawaitable(produced) else produced
97
+ else:
98
+ resolved = claims
99
+ access = self._jwt.sign(record.subject, claims=resolved)
100
+ return TokenPair(
101
+ access_token=access,
102
+ refresh_token=new_opaque,
103
+ expires_in_seconds=int(self._config.access_ttl.total_seconds()),
104
+ )
105
+
106
+ async def logout(self, opaque_refresh_token: str) -> None:
107
+ await self._refresh_store.revoke(opaque_refresh_token)
108
+
109
+ async def logout_everywhere(self, subject: str) -> int:
110
+ """Revoke every refresh token for ``subject`` (password change, MFA re-enrol,
111
+ admin action)."""
112
+ return await self._refresh_store.revoke_all_for_subject(subject)
@@ -0,0 +1,24 @@
1
+ """``AuthConfig`` -- configuration passed to ``AuthModule.for_root(...)``.
2
+
3
+ Lives in its own module so ``AuthService`` (which needs the TTLs) can import it
4
+ without pulling in ``AuthModule`` (which needs ``AuthService`` in its providers).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import timedelta
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class AuthConfig:
15
+ """Every field is required so that a misconfiguration is a startup error,
16
+ not a runtime surprise."""
17
+
18
+ jwt_secret: str
19
+ issuer: str
20
+ algorithm: str = "HS256"
21
+ access_ttl: timedelta = timedelta(minutes=15)
22
+ refresh_ttl: timedelta = timedelta(days=14)
23
+ totp_digits: int = 6
24
+ totp_window: int = 1
@@ -0,0 +1,53 @@
1
+ """Decorators: ``@Public`` and ``CurrentUser``.
2
+
3
+ ``@Public`` marks a route as unauthenticated -- ``AuthGuard`` checks for this
4
+ metadata and short-circuits.
5
+
6
+ ``CurrentUser`` binds the authenticated principal (``request.state.user``, set by
7
+ ``AuthGuard``) into a handler parameter, resolved *after* every guard for the route has
8
+ run -- unlike a plain FastAPI ``Depends``, which would resolve before the guard loop and
9
+ always see an empty ``request.state`` (see ``austial.common.FromRequestState``). If no
10
+ guard ran (e.g. the route is ``@Public``) the value is ``None``, so handlers can accept
11
+ an optional user cleanly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ from austial.common.decorators.params import FromRequestState
20
+ from austial.core.metadata import get_metadata, set_metadata
21
+
22
+ IS_PUBLIC_METADATA = "austial:auth:is_public"
23
+
24
+
25
+ def Public() -> Callable: # noqa: N802 - decorator naming mirrors Nest's ``@Public()``
26
+ """Mark a controller class or a single route handler as public. ``AuthGuard``
27
+ treats a decorated target as unauthenticated and allows the request through."""
28
+
29
+ def decorator(target):
30
+ set_metadata(IS_PUBLIC_METADATA, True)(target)
31
+ return target
32
+
33
+ return decorator
34
+
35
+
36
+ def is_public(target: Any) -> bool:
37
+ """Return ``True`` if ``target`` (a handler or its owning class) is marked public."""
38
+ return bool(get_metadata(IS_PUBLIC_METADATA, target, False))
39
+
40
+
41
+ def CurrentUser() -> Any: # noqa: N802 - Nest-parity naming
42
+ """Inject the authenticated principal (set by ``AuthGuard``) into a handler
43
+ parameter. Returns ``None`` on a ``@Public`` route.
44
+
45
+ Usage::
46
+
47
+ @Controller("/me")
48
+ class MeController:
49
+ @Get()
50
+ def me(self, user=CurrentUser()):
51
+ return {"id": user.subject}
52
+ """
53
+ return FromRequestState("user", default=None)
@@ -0,0 +1,38 @@
1
+ """Auth-specific exceptions.
2
+
3
+ These extend Austial's ``UnauthorizedException`` so the framework's exception dispatch
4
+ maps them to ``401`` without a translation layer, but each carries a distinct type so a
5
+ product-level filter can render a different response body if it wants to.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from austial.common.exceptions import UnauthorizedException
11
+
12
+
13
+ class AuthenticationError(UnauthorizedException):
14
+ """Base for authentication failures."""
15
+
16
+ def __init__(self, message: str = "Authentication failed"):
17
+ super().__init__(message)
18
+
19
+
20
+ class InvalidTokenError(AuthenticationError):
21
+ """The token is malformed, has a bad signature, or fails claims validation."""
22
+
23
+ def __init__(self, message: str = "Invalid token"):
24
+ super().__init__(message)
25
+
26
+
27
+ class TokenExpiredError(AuthenticationError):
28
+ """The token's ``exp`` claim is in the past."""
29
+
30
+ def __init__(self, message: str = "Token expired"):
31
+ super().__init__(message)
32
+
33
+
34
+ class MfaRequiredError(AuthenticationError):
35
+ """A valid password was supplied but MFA verification is still pending."""
36
+
37
+ def __init__(self, message: str = "MFA required"):
38
+ super().__init__(message)
@@ -0,0 +1,118 @@
1
+ """``JwtStrategy`` -- short-lived HS256 access tokens (default 15 min).
2
+
3
+ Design notes:
4
+
5
+ - **Access tokens are the JWT.** They are short-lived and self-contained: the app can
6
+ authenticate a request from the token alone, without a database call.
7
+ - **Refresh tokens are NOT JWTs**; they are opaque strings persisted in a
8
+ ``RefreshTokenStore`` so that they can be revoked. This is issued and rotated by
9
+ ``AuthService``, not here -- ``JwtStrategy`` only signs/verifies JWTs.
10
+ - Signing uses HS256 by default (the config supplies the secret). Callers who need
11
+ RS256 pass ``algorithm="RS256"`` and a PEM key pair; the strategy stays the same.
12
+ - Every token carries ``iss``, ``iat``, ``exp``, ``jti``, and application claims. The
13
+ ``jti`` gives you the hook for token-level revocation if you ever want it, without
14
+ changing the wire format.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import secrets
20
+ from dataclasses import dataclass, field
21
+ from datetime import UTC, datetime, timedelta
22
+ from typing import Any
23
+
24
+ import jwt as _jwt
25
+
26
+ from austial.auth.errors import InvalidTokenError, TokenExpiredError
27
+ from austial.common.decorators.injectable import Injectable
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class TokenPayload:
32
+ """Decoded JWT contents surfaced to callers.
33
+
34
+ ``subject`` is the user identifier; ``claims`` is the application-defined payload
35
+ (tenant id, roles, session id -- whatever was passed to ``sign()``). System claims
36
+ (``iss``, ``iat``, ``exp``, ``jti``) are surfaced as top-level fields.
37
+ """
38
+
39
+ subject: str
40
+ claims: dict[str, Any] = field(default_factory=dict)
41
+ issuer: str | None = None
42
+ issued_at: datetime | None = None
43
+ expires_at: datetime | None = None
44
+ token_id: str | None = None
45
+
46
+
47
+ @Injectable()
48
+ class JwtStrategy:
49
+ """Signs and verifies HS256/RS256 access tokens."""
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ secret: str,
55
+ issuer: str,
56
+ algorithm: str = "HS256",
57
+ access_ttl: timedelta = timedelta(minutes=15),
58
+ clock_skew_seconds: int = 30,
59
+ ) -> None:
60
+ if not secret or len(secret) < 32:
61
+ raise ValueError("JWT secret must be at least 32 characters (see OWASP ASVS V6)")
62
+ self._secret = secret
63
+ self._issuer = issuer
64
+ self._algorithm = algorithm
65
+ self._access_ttl = access_ttl
66
+ self._clock_skew_seconds = clock_skew_seconds
67
+
68
+ def sign(self, subject: str, claims: dict[str, Any] | None = None, *, ttl: timedelta | None = None) -> str:
69
+ """Sign a JWT for ``subject`` with application ``claims``.
70
+
71
+ Passing ``ttl`` overrides the strategy default (useful for step-up MFA
72
+ tokens or short-lived one-time actions)."""
73
+ now = datetime.now(UTC)
74
+ payload: dict[str, Any] = {
75
+ **(claims or {}),
76
+ "iss": self._issuer,
77
+ "sub": subject,
78
+ "iat": int(now.timestamp()),
79
+ "exp": int((now + (ttl or self._access_ttl)).timestamp()),
80
+ "jti": secrets.token_urlsafe(16),
81
+ }
82
+ return _jwt.encode(payload, self._secret, algorithm=self._algorithm)
83
+
84
+ def verify(self, token: str) -> TokenPayload:
85
+ """Verify ``token``; raise ``TokenExpiredError`` or ``InvalidTokenError`` on failure.
86
+
87
+ The caller decides what to do with the decoded payload; this method never
88
+ touches the database and is safe to call from a guard."""
89
+ try:
90
+ data = _jwt.decode(
91
+ token,
92
+ self._secret,
93
+ algorithms=[self._algorithm],
94
+ issuer=self._issuer,
95
+ leeway=self._clock_skew_seconds,
96
+ )
97
+ except _jwt.ExpiredSignatureError as exc:
98
+ raise TokenExpiredError() from exc
99
+ except _jwt.InvalidTokenError as exc: # covers signature, issuer, malformed
100
+ raise InvalidTokenError(str(exc)) from exc
101
+
102
+ subject = data.pop("sub", None)
103
+ if subject is None:
104
+ raise InvalidTokenError("token missing 'sub' claim")
105
+
106
+ issuer = data.pop("iss", None)
107
+ iat = data.pop("iat", None)
108
+ exp = data.pop("exp", None)
109
+ jti = data.pop("jti", None)
110
+
111
+ return TokenPayload(
112
+ subject=str(subject),
113
+ claims=data,
114
+ issuer=issuer,
115
+ issued_at=datetime.fromtimestamp(iat, UTC) if iat is not None else None,
116
+ expires_at=datetime.fromtimestamp(exp, UTC) if exp is not None else None,
117
+ token_id=jti,
118
+ )
@@ -0,0 +1,129 @@
1
+ """Refresh-token store contract + in-memory reference implementation.
2
+
3
+ Refresh tokens are opaque, rotating, and revocable. The framework defines the
4
+ contract; the product supplies a persistent implementation (typically an
5
+ append-only table via ``austial.orm``) so tokens can be revoked on password
6
+ change, MFA re-enrolment, or explicit sign-out. The in-memory implementation is
7
+ useful in tests and in local dev without a database.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import secrets
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime, timedelta
15
+ from typing import Protocol, runtime_checkable
16
+
17
+ from austial.common.decorators.injectable import Injectable
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class RefreshTokenRecord:
22
+ """One row in a refresh-token store."""
23
+
24
+ token_id: str
25
+ subject: str
26
+ expires_at: datetime
27
+ revoked_at: datetime | None = None
28
+ session_id: str | None = None
29
+
30
+
31
+ @runtime_checkable
32
+ class RefreshTokenStore(Protocol):
33
+ """Inject this interface; supply an implementation from the product's persistence layer."""
34
+
35
+ async def issue(
36
+ self, subject: str, *, ttl: timedelta, session_id: str | None = None
37
+ ) -> tuple[str, RefreshTokenRecord]:
38
+ """Return ``(opaque_token, record)``. The opaque token is what the client keeps."""
39
+ ...
40
+
41
+ async def rotate(self, opaque_token: str, *, ttl: timedelta) -> tuple[str, RefreshTokenRecord]:
42
+ """Revoke the presented token and issue a replacement in one atomic step.
43
+ Raises ``LookupError`` if the token is unknown, expired, or already revoked."""
44
+ ...
45
+
46
+ async def revoke(self, opaque_token: str) -> None:
47
+ """Idempotent: revoking an unknown or already-revoked token is a no-op."""
48
+ ...
49
+
50
+ async def revoke_all_for_subject(self, subject: str) -> int:
51
+ """Revoke every active refresh token for ``subject``; return the count revoked."""
52
+ ...
53
+
54
+
55
+ @Injectable()
56
+ class InMemoryRefreshTokenStore:
57
+ """Reference implementation for tests and local dev.
58
+
59
+ Tokens are hashed before storage (SHA-256) so a memory dump does not surrender them
60
+ verbatim; the raw opaque token is only ever held by the caller. Not for production."""
61
+
62
+ def __init__(self) -> None:
63
+ self._records: dict[str, RefreshTokenRecord] = {}
64
+
65
+ def _hash(self, opaque: str) -> str:
66
+ import hashlib
67
+
68
+ return hashlib.sha256(opaque.encode()).hexdigest()
69
+
70
+ async def issue(
71
+ self, subject: str, *, ttl: timedelta, session_id: str | None = None
72
+ ) -> tuple[str, RefreshTokenRecord]:
73
+ opaque = secrets.token_urlsafe(48)
74
+ token_id = self._hash(opaque)
75
+ record = RefreshTokenRecord(
76
+ token_id=token_id,
77
+ subject=subject,
78
+ expires_at=datetime.now(UTC) + ttl,
79
+ session_id=session_id,
80
+ )
81
+ self._records[token_id] = record
82
+ return opaque, record
83
+
84
+ async def rotate(self, opaque_token: str, *, ttl: timedelta) -> tuple[str, RefreshTokenRecord]:
85
+ token_id = self._hash(opaque_token)
86
+ existing = self._records.get(token_id)
87
+ if existing is None:
88
+ raise LookupError("unknown refresh token")
89
+ if existing.revoked_at is not None:
90
+ raise LookupError("refresh token already revoked")
91
+ if existing.expires_at < datetime.now(UTC):
92
+ raise LookupError("refresh token expired")
93
+
94
+ self._records[token_id] = RefreshTokenRecord(
95
+ token_id=existing.token_id,
96
+ subject=existing.subject,
97
+ expires_at=existing.expires_at,
98
+ revoked_at=datetime.now(UTC),
99
+ session_id=existing.session_id,
100
+ )
101
+ return await self.issue(existing.subject, ttl=ttl, session_id=existing.session_id)
102
+
103
+ async def revoke(self, opaque_token: str) -> None:
104
+ token_id = self._hash(opaque_token)
105
+ existing = self._records.get(token_id)
106
+ if existing is None or existing.revoked_at is not None:
107
+ return
108
+ self._records[token_id] = RefreshTokenRecord(
109
+ token_id=existing.token_id,
110
+ subject=existing.subject,
111
+ expires_at=existing.expires_at,
112
+ revoked_at=datetime.now(UTC),
113
+ session_id=existing.session_id,
114
+ )
115
+
116
+ async def revoke_all_for_subject(self, subject: str) -> int:
117
+ revoked = 0
118
+ now = datetime.now(UTC)
119
+ for token_id, record in list(self._records.items()):
120
+ if record.subject == subject and record.revoked_at is None:
121
+ self._records[token_id] = RefreshTokenRecord(
122
+ token_id=record.token_id,
123
+ subject=record.subject,
124
+ expires_at=record.expires_at,
125
+ revoked_at=now,
126
+ session_id=record.session_id,
127
+ )
128
+ revoked += 1
129
+ return revoked
@@ -0,0 +1,58 @@
1
+ """``TotpStrategy`` -- RFC 6238 TOTP for multi-factor authentication.
2
+
3
+ Generates enrolment secrets, builds the ``otpauth://`` provisioning URI (the QR-code
4
+ payload authenticators consume), verifies 6-digit codes with a one-window drift
5
+ allowance, and produces recovery codes for the account-recovery flow.
6
+
7
+ The secret is *not* stored by this class; the product persists it against the user
8
+ record. Every method here is pure/stateless so it is trivial to unit-test.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import secrets
14
+
15
+ import pyotp
16
+
17
+ from austial.common.decorators.injectable import Injectable
18
+
19
+
20
+ @Injectable()
21
+ class TotpStrategy:
22
+ """RFC 6238 TOTP -- 30-second step, SHA-1 digest, 6-digit codes."""
23
+
24
+ def __init__(self, *, issuer: str, digits: int = 6, interval_seconds: int = 30, window: int = 1) -> None:
25
+ self._issuer = issuer
26
+ self._digits = digits
27
+ self._interval = interval_seconds
28
+ # ``window`` is the number of intervals on either side of "now" we accept, to
29
+ # absorb clock drift on the client. 1 = ±30 seconds; do not exceed 2 (per ASVS).
30
+ self._window = min(window, 2)
31
+
32
+ def generate_secret(self) -> str:
33
+ """Return a fresh base32-encoded secret to persist for the user."""
34
+ return pyotp.random_base32()
35
+
36
+ def provisioning_uri(self, secret: str, account_name: str) -> str:
37
+ """Return the ``otpauth://totp/...`` URI the authenticator app scans."""
38
+ totp = pyotp.TOTP(secret, digits=self._digits, interval=self._interval)
39
+ return totp.provisioning_uri(name=account_name, issuer_name=self._issuer)
40
+
41
+ def verify(self, secret: str, code: str) -> bool:
42
+ """Constant-time TOTP verification with one-window drift allowance."""
43
+ if not code or not code.isdigit() or len(code) != self._digits:
44
+ return False
45
+ totp = pyotp.TOTP(secret, digits=self._digits, interval=self._interval)
46
+ return totp.verify(code, valid_window=self._window)
47
+
48
+ def generate_recovery_codes(self, count: int = 10, *, group_size: int = 5) -> list[str]:
49
+ """Return ``count`` cryptographically-random single-use recovery codes.
50
+
51
+ Codes are grouped for human readability (``ABCDE-FGHIJ``); persist them **hashed**
52
+ (via ``Argon2Hasher``) so that a database leak does not compromise them."""
53
+ codes: list[str] = []
54
+ alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no ambiguous glyphs
55
+ for _ in range(count):
56
+ raw = "".join(secrets.choice(alphabet) for _ in range(group_size * 2))
57
+ codes.append(f"{raw[:group_size]}-{raw[group_size:]}")
58
+ return codes
@@ -0,0 +1,271 @@
1
+ """``austial.auth`` unit tests.
2
+
3
+ Covers the five load-bearing behaviours a product depends on:
4
+
5
+ - Argon2 hash-and-verify round-trip; a wrong password fails; a hash produced with
6
+ weaker parameters is flagged for rehash.
7
+ - JWT sign-and-verify round-trip; an expired token surfaces ``TokenExpiredError``;
8
+ a tampered token surfaces ``InvalidTokenError``.
9
+ - TOTP secret-and-verify round-trip; the wrong code fails; recovery codes are unique
10
+ and correctly grouped.
11
+ - ``InMemoryRefreshTokenStore`` issue → rotate → revoke lifecycle.
12
+ - ``AuthGuard`` allows ``@Public`` routes, rejects requests without a bearer token,
13
+ and populates ``request.state.user`` on a valid token.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import timedelta
19
+ from types import SimpleNamespace
20
+
21
+ import pytest
22
+
23
+ from austial.auth import (
24
+ Argon2Hasher,
25
+ AuthConfig,
26
+ AuthGuard,
27
+ AuthPrincipal,
28
+ AuthService,
29
+ InMemoryRefreshTokenStore,
30
+ InvalidTokenError,
31
+ JwtStrategy,
32
+ PasswordVerificationError,
33
+ Public,
34
+ TokenExpiredError,
35
+ TotpStrategy,
36
+ )
37
+ from austial.common import ExecutionContext
38
+ from austial.common.exceptions import UnauthorizedException
39
+
40
+
41
+ class _FakeExecutionContext(ExecutionContext):
42
+ def __init__(self, request, controller_class, handler):
43
+ self._request = request
44
+ self._controller_class = controller_class
45
+ self._handler = handler
46
+
47
+ def get_request(self):
48
+ return self._request
49
+
50
+ def get_class(self):
51
+ return self._controller_class
52
+
53
+ def get_handler(self):
54
+ return self._handler
55
+
56
+
57
+ def _fake_request(headers: dict[str, str] | None = None):
58
+ class _State:
59
+ pass
60
+
61
+ return SimpleNamespace(headers=headers or {}, state=_State())
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Argon2Hasher
66
+
67
+
68
+ def test_argon2_hash_and_verify_round_trip():
69
+ hasher = Argon2Hasher()
70
+ encoded = hasher.hash("hunter2!")
71
+ hasher.verify("hunter2!", encoded) # must not raise
72
+
73
+
74
+ def test_argon2_verify_rejects_wrong_password():
75
+ hasher = Argon2Hasher()
76
+ encoded = hasher.hash("hunter2!")
77
+ with pytest.raises(PasswordVerificationError):
78
+ hasher.verify("nope", encoded)
79
+
80
+
81
+ def test_argon2_needs_rehash_flags_weaker_parameters():
82
+ weak = Argon2Hasher(time_cost=1, memory_cost=8 * 1024, parallelism=1)
83
+ strong = Argon2Hasher(time_cost=3, memory_cost=64 * 1024, parallelism=2)
84
+ old = weak.hash("hunter2!")
85
+ assert strong.needs_rehash(old) is True
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # JwtStrategy
90
+
91
+
92
+ def _jwt() -> JwtStrategy:
93
+ return JwtStrategy(secret="x" * 40, issuer="austial-tests", access_ttl=timedelta(minutes=15))
94
+
95
+
96
+ def test_jwt_sign_and_verify_round_trip():
97
+ jwt = _jwt()
98
+ token = jwt.sign("user-1", claims={"tenant": "acme", "roles": ["ops"]})
99
+ decoded = jwt.verify(token)
100
+ assert decoded.subject == "user-1"
101
+ assert decoded.claims == {"tenant": "acme", "roles": ["ops"]}
102
+ assert decoded.issuer == "austial-tests"
103
+ assert decoded.token_id is not None
104
+
105
+
106
+ def test_jwt_rejects_expired_token():
107
+ # ttl beyond the default 30s clock-skew leeway
108
+ jwt = JwtStrategy(secret="x" * 40, issuer="austial-tests", access_ttl=timedelta(seconds=-120))
109
+ token = jwt.sign("user-1")
110
+ with pytest.raises(TokenExpiredError):
111
+ jwt.verify(token)
112
+
113
+
114
+ def test_jwt_rejects_tampered_signature():
115
+ jwt = _jwt()
116
+ token = jwt.sign("user-1")
117
+ tampered = token[:-2] + ("aa" if not token.endswith("aa") else "bb")
118
+ with pytest.raises(InvalidTokenError):
119
+ jwt.verify(tampered)
120
+
121
+
122
+ def test_jwt_secret_must_be_long_enough():
123
+ with pytest.raises(ValueError):
124
+ JwtStrategy(secret="short", issuer="x")
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # TotpStrategy
129
+
130
+
131
+ def test_totp_secret_and_verify_round_trip():
132
+ totp = TotpStrategy(issuer="austial-tests")
133
+ secret = totp.generate_secret()
134
+ import pyotp
135
+
136
+ code = pyotp.TOTP(secret).now()
137
+ assert totp.verify(secret, code) is True
138
+
139
+
140
+ def test_totp_rejects_wrong_code():
141
+ totp = TotpStrategy(issuer="austial-tests")
142
+ secret = totp.generate_secret()
143
+ assert totp.verify(secret, "000000") is False
144
+ assert totp.verify(secret, "abc") is False
145
+ assert totp.verify(secret, "") is False
146
+
147
+
148
+ def test_totp_recovery_codes_are_unique_and_formatted():
149
+ totp = TotpStrategy(issuer="austial-tests")
150
+ codes = totp.generate_recovery_codes(count=8, group_size=5)
151
+ assert len(codes) == 8
152
+ assert len(set(codes)) == 8
153
+ for c in codes:
154
+ left, _, right = c.partition("-")
155
+ assert len(left) == 5 and len(right) == 5
156
+
157
+
158
+ def test_totp_provisioning_uri_is_otpauth():
159
+ totp = TotpStrategy(issuer="austial-tests")
160
+ uri = totp.provisioning_uri("SECRETSECRETSECRET", "user@example.com")
161
+ assert uri.startswith("otpauth://totp/")
162
+ assert "issuer=austial-tests" in uri
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # InMemoryRefreshTokenStore
167
+
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_refresh_store_issue_rotate_revoke_lifecycle():
171
+ store = InMemoryRefreshTokenStore()
172
+ opaque, record = await store.issue("user-1", ttl=timedelta(minutes=5))
173
+ assert record.subject == "user-1"
174
+
175
+ new_opaque, new_record = await store.rotate(opaque, ttl=timedelta(minutes=5))
176
+ assert new_opaque != opaque
177
+ assert new_record.subject == "user-1"
178
+
179
+ # rotating the old token again is now rejected
180
+ with pytest.raises(LookupError):
181
+ await store.rotate(opaque, ttl=timedelta(minutes=5))
182
+
183
+ await store.revoke(new_opaque)
184
+ # revoking is idempotent
185
+ await store.revoke(new_opaque)
186
+
187
+
188
+ @pytest.mark.asyncio
189
+ async def test_refresh_store_revoke_all_for_subject():
190
+ store = InMemoryRefreshTokenStore()
191
+ await store.issue("u1", ttl=timedelta(minutes=5))
192
+ await store.issue("u1", ttl=timedelta(minutes=5))
193
+ await store.issue("u2", ttl=timedelta(minutes=5))
194
+ revoked = await store.revoke_all_for_subject("u1")
195
+ assert revoked == 2
196
+ # calling again reports 0
197
+ assert await store.revoke_all_for_subject("u1") == 0
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # AuthGuard
202
+
203
+
204
+ @pytest.mark.asyncio
205
+ async def test_auth_guard_allows_public_routes():
206
+ guard = AuthGuard(_jwt())
207
+
208
+ @Public()
209
+ def _handler():
210
+ pass
211
+
212
+ ctx = _FakeExecutionContext(_fake_request(), object, _handler)
213
+ assert await guard.can_activate(ctx) is True
214
+
215
+
216
+ @pytest.mark.asyncio
217
+ async def test_auth_guard_rejects_missing_bearer():
218
+ guard = AuthGuard(_jwt())
219
+
220
+ def _handler():
221
+ pass
222
+
223
+ ctx = _FakeExecutionContext(_fake_request(headers={}), object, _handler)
224
+ with pytest.raises(UnauthorizedException):
225
+ await guard.can_activate(ctx)
226
+
227
+
228
+ @pytest.mark.asyncio
229
+ async def test_auth_guard_populates_request_state_on_valid_token():
230
+ jwt = _jwt()
231
+ token = jwt.sign("user-42", claims={"tenant": "acme"})
232
+ guard = AuthGuard(jwt)
233
+
234
+ def _handler():
235
+ pass
236
+
237
+ req = _fake_request(headers={"Authorization": f"Bearer {token}"})
238
+ ctx = _FakeExecutionContext(req, object, _handler)
239
+ assert await guard.can_activate(ctx) is True
240
+ assert isinstance(req.state.user, AuthPrincipal)
241
+ assert req.state.user.subject == "user-42"
242
+ assert req.state.user.claims == {"tenant": "acme"}
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # AuthService orchestration
247
+
248
+
249
+ @pytest.mark.asyncio
250
+ async def test_auth_service_issue_and_refresh_round_trip():
251
+ jwt = _jwt()
252
+ totp = TotpStrategy(issuer="austial-tests")
253
+ hasher = Argon2Hasher()
254
+ store = InMemoryRefreshTokenStore()
255
+ config = AuthConfig(jwt_secret="x" * 40, issuer="austial-tests")
256
+ svc = AuthService(jwt, totp, hasher, store, config)
257
+
258
+ tokens = await svc.issue_tokens("user-1", claims={"tenant": "acme"})
259
+ decoded = jwt.verify(tokens.access_token)
260
+ assert decoded.subject == "user-1"
261
+
262
+ refreshed = await svc.refresh(tokens.refresh_token)
263
+ assert refreshed.refresh_token != tokens.refresh_token
264
+ assert jwt.verify(refreshed.access_token).subject == "user-1"
265
+
266
+ await svc.logout(refreshed.refresh_token)
267
+ # a revoked refresh token cannot be used again
268
+ from austial.auth.errors import AuthenticationError
269
+
270
+ with pytest.raises(AuthenticationError):
271
+ await svc.refresh(refreshed.refresh_token)