austial-auth 0.2.0__py3-none-any.whl
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.
- austial/auth/__init__.py +57 -0
- austial/auth/argon2_hasher.py +73 -0
- austial/auth/auth_guard.py +67 -0
- austial/auth/auth_module.py +89 -0
- austial/auth/auth_service.py +112 -0
- austial/auth/config.py +24 -0
- austial/auth/decorators.py +53 -0
- austial/auth/errors.py +38 -0
- austial/auth/jwt_strategy.py +118 -0
- austial/auth/refresh_token_store.py +129 -0
- austial/auth/totp_strategy.py +58 -0
- austial_auth-0.2.0.dist-info/METADATA +10 -0
- austial_auth-0.2.0.dist-info/RECORD +14 -0
- austial_auth-0.2.0.dist-info/WHEEL +4 -0
austial/auth/__init__.py
ADDED
|
@@ -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)
|
austial/auth/config.py
ADDED
|
@@ -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)
|
austial/auth/errors.py
ADDED
|
@@ -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,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,14 @@
|
|
|
1
|
+
austial/auth/__init__.py,sha256=U16iP0fmIIoP5AU1jJH8hTqYCmw5M0htLIrbQh-7UzQ,2033
|
|
2
|
+
austial/auth/argon2_hasher.py,sha256=kCAa7yM9s8M8WibT5paF2LLLYfbblNjki_QGP3hf2yc,2736
|
|
3
|
+
austial/auth/auth_guard.py,sha256=9B0-KP2zfGkUnkJ5ApDxmPVeZmpVUpeFJN0aD7Osh_o,2691
|
|
4
|
+
austial/auth/auth_module.py,sha256=qKv15jujLHJhx_f6usUsxoXPJncKydYSOfWJO4C0HPA,3480
|
|
5
|
+
austial/auth/auth_service.py,sha256=6w3bU72vSBHsjVpXD1nndpXFRFDO78cfY9777s6zKQE,4742
|
|
6
|
+
austial/auth/config.py,sha256=ukZjmjgtn_3C06fYPaCCScI6sx-tL2bwT60O-z20VNk,723
|
|
7
|
+
austial/auth/decorators.py,sha256=y2gCKNXYHWU4pP6cwCBkOYULCx1q6ISLZboTLxZBd4Y,1900
|
|
8
|
+
austial/auth/errors.py,sha256=BBNVQ92Dgff-VyMiwyT0iiuzQZVfmGRx-X0HCCXedVI,1204
|
|
9
|
+
austial/auth/jwt_strategy.py,sha256=CuW9QXwqtq24UCwY_5PVZErYP1zvB7GWfUfodiqpo8Y,4476
|
|
10
|
+
austial/auth/refresh_token_store.py,sha256=FUkjF5aPmXy7mRXG7HhGlT4t7-KhRZrn7AyNkY7S5QA,4865
|
|
11
|
+
austial/auth/totp_strategy.py,sha256=Ny9EODeYgR2sbevsdpta2BP4r63v5r9JPQKoQTraw80,2628
|
|
12
|
+
austial_auth-0.2.0.dist-info/METADATA,sha256=hpM15T7AewpIRAVc85HaPZDixzkgitPELVNZq0wjsXE,380
|
|
13
|
+
austial_auth-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
austial_auth-0.2.0.dist-info/RECORD,,
|