reauth 0.1.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.
reauth/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """The authentication toolkit for Python"""
2
+
3
+ __version__ = "0.1.0"
reauth/amr.py ADDED
@@ -0,0 +1,96 @@
1
+ """Authentication Method Reference Values (amr) per RFC 8176.
2
+
3
+ This module defines the standard Authentication Method Reference values
4
+ as specified in RFC 8176 for use in JWT amr claims. These values represent
5
+ the authentication methods used in an authentication event.
6
+
7
+ See: https://datatracker.ietf.org/doc/html/rfc8176
8
+ """
9
+
10
+ from enum import StrEnum
11
+
12
+
13
+ class AuthenticationMethodReference(StrEnum):
14
+ """Standard Authentication Method Reference values (amr) per RFC 8176.
15
+
16
+ These values are used in the 'amr' claim of JSON Web Tokens (JWT) to
17
+ identify the authentication methods used in an authentication event.
18
+
19
+ Each value represents a family of closely related authentication methods.
20
+ The registry is maintained by IANA and is extensible.
21
+ """
22
+
23
+ # Biometric methods
24
+ FACE = "face"
25
+ """Facial recognition biometric authentication."""
26
+
27
+ FPT = "fpt"
28
+ """Fingerprint biometric authentication."""
29
+
30
+ IRIS = "iris"
31
+ """Iris scan biometric authentication."""
32
+
33
+ RETINA = "retina"
34
+ """Retina scan biometric authentication."""
35
+
36
+ VBM = "vbm"
37
+ """Voice biometric authentication."""
38
+
39
+ # Knowledge-based methods
40
+ KBA = "kba"
41
+ """Knowledge-based authentication (e.g., security questions)."""
42
+
43
+ PIN = "pin"
44
+ """Personal Identification Number or pattern authentication."""
45
+
46
+ PWD = "pwd"
47
+ """Password-based authentication."""
48
+
49
+ # Possession-based methods
50
+ HWK = "hwk"
51
+ """Proof-of-Possession of a hardware-secured key."""
52
+
53
+ OTP = "otp"
54
+ """One-time password (HOTP/TOTP)."""
55
+
56
+ SC = "sc"
57
+ """Smart card authentication."""
58
+
59
+ SWK = "swk"
60
+ """Proof-of-Possession of a software-secured key."""
61
+
62
+ # Out-of-band methods
63
+ SMS = "sms"
64
+ """SMS text message confirmation."""
65
+
66
+ TEL = "tel"
67
+ """Telephone call confirmation."""
68
+
69
+ EMAIL = "email"
70
+ """Email-based OTP or magic link authentication.
71
+
72
+ This is a custom extension to RFC 8176, not part of the standard.
73
+ """
74
+
75
+ # Multi-factor and risk-based methods
76
+ MFA = "mfa"
77
+ """Multiple-factor authentication."""
78
+
79
+ MCA = "mca"
80
+ """Multiple-channel authentication."""
81
+
82
+ RBA = "rba"
83
+ """Risk-based authentication."""
84
+
85
+ # Special purpose
86
+ GEO = "geo"
87
+ """Geolocation-based authentication."""
88
+
89
+ USER = "user"
90
+ """User presence test."""
91
+
92
+ WIA = "wia"
93
+ """Windows Integrated Authentication."""
94
+
95
+
96
+ __all__ = ["AuthenticationMethodReference"]
@@ -0,0 +1,217 @@
1
+ import abc
2
+ import dataclasses
3
+ import datetime
4
+ import typing
5
+
6
+ from .amr import AuthenticationMethodReference
7
+ from .crypto import TokenHash, generate_token_hash_pair, get_token_hash
8
+ from .exceptions import ReauthException
9
+ from .factors import FactorBase
10
+ from .timestamp import get_current_timestamp
11
+
12
+
13
+ @dataclasses.dataclass
14
+ class AuthenticationSession:
15
+ id: typing.Any | None
16
+ token_hash: TokenHash
17
+ expires_at: int
18
+ identity_id: typing.Any | None
19
+ amr: list[AuthenticationMethodReference] = dataclasses.field(default_factory=list)
20
+
21
+ def is_expired(self) -> bool:
22
+ """
23
+ Check if the session has expired.
24
+
25
+ Returns:
26
+ True if the session has expired, False otherwise.
27
+ """
28
+ return get_current_timestamp() >= self.expires_at
29
+
30
+
31
+ class AuthenticationSessionException(ReauthException):
32
+ """Base exception for authentication session errors."""
33
+
34
+ pass
35
+
36
+
37
+ class InvalidSessionTokenException(AuthenticationSessionException):
38
+ """Raised when a token is invalid or does not correspond to any session."""
39
+
40
+ pass
41
+
42
+
43
+ class ExpiredSessionException(AuthenticationSessionException):
44
+ """Raised when a session has expired."""
45
+
46
+ pass
47
+
48
+
49
+ class UnavailableFactorException(AuthenticationSessionException):
50
+ """Raised when trying to advance a session with a factor that is not available for the session."""
51
+
52
+ pass
53
+
54
+
55
+ class AuthenticationSessionService(abc.ABC):
56
+ """
57
+ Abstract base class for managing authentication sessions.
58
+
59
+ An authentication session represents a pre-auth state that can be used to
60
+ authenticate a user, allowing to manage multi-factor authentication flows.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ hash_secret: str,
67
+ factors: set[FactorBase],
68
+ token_prefix: str = "ls_",
69
+ lifetime: datetime.timedelta = datetime.timedelta(minutes=15),
70
+ ) -> None:
71
+ self.hash_secret = hash_secret
72
+ self.factors = factors
73
+ self.token_prefix = token_prefix
74
+ self.lifetime = lifetime
75
+
76
+ async def create(self) -> tuple[str, AuthenticationSession]:
77
+ """
78
+ Create a fresh authentication session.
79
+
80
+ Returns:
81
+ A tuple of (token, AuthenticationSession instance).
82
+ """
83
+ token, token_hash = generate_token_hash_pair(
84
+ secret=self.hash_secret, prefix=self.token_prefix
85
+ )
86
+ authentication_session = AuthenticationSession(
87
+ id=None,
88
+ token_hash=token_hash,
89
+ expires_at=get_current_timestamp() + int(self.lifetime.total_seconds()),
90
+ identity_id=None,
91
+ )
92
+ authentication_session.id = await self.insert(authentication_session)
93
+ return token, authentication_session
94
+
95
+ async def get_by_token(self, token: str) -> AuthenticationSession:
96
+ """
97
+ Validate a token and return the corresponding authentication session.
98
+
99
+ Args:
100
+ token: The token to validate.
101
+
102
+ Returns:
103
+ The corresponding AuthenticationSession instance.
104
+
105
+ Raises:
106
+ InvalidTokenException: If the token is invalid or does not correspond to any session.
107
+ ExpiredSessionException: If the session corresponding to the token has expired.
108
+ """
109
+ token_hash = get_token_hash(token, secret=self.hash_secret)
110
+ authentication_session = await self.get_by_token_hash(token_hash)
111
+ if authentication_session is None:
112
+ raise InvalidSessionTokenException()
113
+ if authentication_session.is_expired():
114
+ raise ExpiredSessionException()
115
+ return authentication_session
116
+
117
+ async def get_available_factors(
118
+ self, authentication_session: AuthenticationSession
119
+ ) -> set[FactorBase]:
120
+ """
121
+ Get the set of available factors for a given authentication session.
122
+
123
+ Args:
124
+ authentication_session: The AuthenticationSession instance to get available factors for.
125
+
126
+ Returns:
127
+ A set of FactorBase instances representing the available factors for the session.
128
+ """
129
+ available: set[FactorBase] = set()
130
+ for factor in self.factors:
131
+ # Already used?
132
+ if factor.AMR in authentication_session.amr:
133
+ continue
134
+ # Meets min_prior_factors?
135
+ if len(authentication_session.amr) < factor.min_prior_factors:
136
+ continue
137
+ # Enrolled for the identity in the session (if any)?
138
+ if authentication_session.identity_id is not None:
139
+ factor_enrollment = await factor.get_enrollment(
140
+ authentication_session.identity_id
141
+ )
142
+ if factor_enrollment is None:
143
+ continue
144
+ available.add(factor)
145
+ return available
146
+
147
+ async def advance(
148
+ self,
149
+ authentication_session: AuthenticationSession,
150
+ identity_id: typing.Any,
151
+ factor: FactorBase,
152
+ ) -> AuthenticationSession:
153
+ """
154
+ Advance an authentication session by marking a factor as completed.
155
+
156
+ Args:
157
+ authentication_session: The AuthenticationSession instance to advance.
158
+ identity_id: The ID of the identity that has completed the factor.
159
+ factor: The FactorBase instance representing the factor that has been completed.
160
+
161
+ Returns:
162
+ The updated AuthenticationSession instance.
163
+
164
+ Raises:
165
+ UnavailableFactorException: If the factor is not available for the session.
166
+ """
167
+ available_factors = await self.get_available_factors(authentication_session)
168
+ if factor not in available_factors:
169
+ raise UnavailableFactorException()
170
+
171
+ authentication_session.identity_id = identity_id
172
+ authentication_session.amr.append(factor.AMR)
173
+ await self.update(authentication_session)
174
+
175
+ return authentication_session
176
+
177
+ @abc.abstractmethod
178
+ async def insert(self, authentication_session: AuthenticationSession) -> typing.Any:
179
+ """
180
+ Insert an authentication session into a persistent store.
181
+
182
+ Implementers should implement this method.
183
+
184
+ Args:
185
+ authentication_session: The AuthenticationSession instance to insert.
186
+
187
+ Returns:
188
+ The ID of the inserted authentication session.
189
+ """
190
+ ...
191
+
192
+ @abc.abstractmethod
193
+ async def get_by_token_hash(self, token_hash: str) -> AuthenticationSession | None:
194
+ """
195
+ Retrieve an authentication session by its token hash from the persistent store.
196
+
197
+ Implementers should implement this method.
198
+
199
+ Args:
200
+ token_hash: The hash of the token to look up.
201
+
202
+ Returns:
203
+ The corresponding AuthenticationSession instance, or None if not found.
204
+ """
205
+ ...
206
+
207
+ @abc.abstractmethod
208
+ async def update(self, authentication_session: AuthenticationSession) -> None:
209
+ """
210
+ Update an authentication session in the persistent store.
211
+
212
+ Implementers should implement this method.
213
+
214
+ Args:
215
+ authentication_session: The AuthenticationSession instance to update.
216
+ """
217
+ ...
reauth/crypto.py ADDED
@@ -0,0 +1,112 @@
1
+ """A set of cryptographic utilities."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import secrets
6
+ import string
7
+ import typing
8
+ import zlib
9
+
10
+ TokenHash = typing.NewType("TokenHash", str)
11
+
12
+
13
+ def _crc32_to_base62(number: int) -> str:
14
+ characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
15
+ base = len(characters)
16
+ encoded = ""
17
+ while number:
18
+ number, remainder = divmod(number, base)
19
+ encoded = characters[remainder] + encoded
20
+ return encoded.zfill(6) # Ensure the checksum is 6 characters long
21
+
22
+
23
+ def get_token_hash(token: str, *, secret: str) -> TokenHash:
24
+ """Compute HMAC-SHA256 hash of a token.
25
+
26
+ Args:
27
+ token: The token value to hash.
28
+ secret: The secret key used for HMAC computation.
29
+
30
+ Returns:
31
+ Hexadecimal string of the HMAC-SHA256 hash.
32
+ """
33
+ hash = hmac.new(secret.encode("ascii"), token.encode("ascii"), hashlib.sha256)
34
+ return TokenHash(hash.hexdigest())
35
+
36
+
37
+ def generate_token(*, prefix: str = "") -> str:
38
+ """Generate a high-entropy random token with embedded checksum.
39
+
40
+ The token consists of a random string of 37 alphanumeric characters
41
+ followed by a 6-character base62-encoded CRC32 checksum.
42
+
43
+ The CRC32 checksum provides fast pre-validation to filter out malformed
44
+ or corrupted tokens before more expensive HMAC verification or database
45
+ lookups. It is NOT a cryptographic security mechanism.
46
+
47
+ Args:
48
+ prefix: Optional prefix prepended to the generated token.
49
+
50
+ Returns:
51
+ A token string with format: {prefix}{37_random_chars}{6_checksum_chars}.
52
+ """
53
+ # Generate a high entropy random token
54
+ token = "".join(
55
+ secrets.choice(string.ascii_letters + string.digits) for _ in range(37)
56
+ )
57
+
58
+ # Calculate a 32-bit CRC checksum
59
+ checksum = zlib.crc32(token.encode("utf-8")) & 0xFFFFFFFF
60
+ checksum_base62 = _crc32_to_base62(checksum)
61
+
62
+ # Concatenate the prefix, token, and checksum
63
+ return f"{prefix}{token}{checksum_base62}"
64
+
65
+
66
+ def generate_token_hash_pair(*, secret: str, prefix: str = "") -> tuple[str, TokenHash]:
67
+ """Generate a token and its HMAC-SHA256 hash pair.
68
+
69
+ Generates a high-entropy token with embedded checksum and computes its
70
+ HMAC-SHA256 hash using the provided secret. This pattern allows storing
71
+ only the hash in the database while the raw token is provided to the user.
72
+
73
+ Args:
74
+ secret: The secret key used for HMAC computation.
75
+ prefix: Optional prefix prepended to the generated token.
76
+
77
+ Returns:
78
+ A tuple of (token, hash) where token is the raw value and hash is
79
+ its HMAC-SHA256 hexadecimal digest.
80
+ """
81
+ token = generate_token(prefix=prefix)
82
+ return token, get_token_hash(token, secret=secret)
83
+
84
+
85
+ def generate_code_hash_pair(*, secret: str, length: int = 6) -> tuple[str, TokenHash]:
86
+ """Generate a 6-alphanumeric code and its HMAC-SHA256 hash pair.
87
+
88
+ This is useful for generating OTP codes where the raw code is sent to the
89
+ user and only the hash is stored for verification.
90
+
91
+ Args:
92
+ secret: The secret key used for HMAC computation.
93
+ length: The length of the numeric code to generate (default is 6).
94
+
95
+ Returns:
96
+ A tuple of (code, hash) where code is the raw code and hash is
97
+ its HMAC-SHA256 hexadecimal digest.
98
+ """
99
+ code = "".join(
100
+ secrets.choice(string.ascii_uppercase + string.digits) for _ in range(length)
101
+ )
102
+ code_hash = get_token_hash(code, secret=secret)
103
+ return code, code_hash
104
+
105
+
106
+ __all__ = [
107
+ "TokenHash",
108
+ "get_token_hash",
109
+ "generate_code_hash_pair",
110
+ "generate_token",
111
+ "generate_token_hash_pair",
112
+ ]
reauth/exceptions.py ADDED
@@ -0,0 +1,9 @@
1
+ class ReauthException(Exception):
2
+ """Base exception for reauth."""
3
+
4
+ def __init__(self, message: str | None = None) -> None:
5
+ self.message = message
6
+ super().__init__(message)
7
+
8
+
9
+ __all__ = ["ReauthException"]
@@ -0,0 +1,3 @@
1
+ from .base import FactorBase
2
+
3
+ __all__ = ["FactorBase"]
reauth/factors/base.py ADDED
@@ -0,0 +1,44 @@
1
+ import abc
2
+ import typing
3
+
4
+ from ..amr import AuthenticationMethodReference
5
+
6
+
7
+ class FactorEnrollment(typing.Protocol):
8
+ """
9
+ Protocol for factor enrollment objects,
10
+ which represent the state of an enrolled factor for a given identity.
11
+ """
12
+
13
+ id: typing.Any | None
14
+ identity_id: typing.Any
15
+
16
+
17
+ class FactorBase(abc.ABC):
18
+ """Base abstract class for all factor services."""
19
+
20
+ AMR: typing.ClassVar[AuthenticationMethodReference]
21
+
22
+ def __init__(self, *, min_prior_factors: int = 0) -> None:
23
+ """
24
+ Initialize the factor base.
25
+
26
+ Args:
27
+ min_prior_factors: Minimum number of prior factors that must complete
28
+ before this factor can be used. Defaults to 0 (can be first factor).
29
+ """
30
+ self.min_prior_factors = min_prior_factors
31
+
32
+ @abc.abstractmethod
33
+ async def get_enrollment(self, identity_id: typing.Any) -> FactorEnrollment | None:
34
+ """
35
+ Get the enrollment information for a given identity.
36
+
37
+ Args:
38
+ identity_id: The ID of the identity to get the factor configuration for.
39
+
40
+ Returns:
41
+ The enrollment information for the factor,
42
+ or None if the factor is not enrolled for the identity.
43
+ """
44
+ ...
@@ -0,0 +1,198 @@
1
+ import abc
2
+ import dataclasses
3
+ import datetime
4
+ import typing
5
+
6
+ from reauth.exceptions import ReauthException
7
+
8
+ from ..amr import AuthenticationMethodReference
9
+ from ..crypto import generate_code_hash_pair, get_token_hash
10
+ from ..timestamp import get_current_timestamp
11
+ from .base import FactorBase
12
+
13
+
14
+ @dataclasses.dataclass
15
+ class EmailOTPEnrollment:
16
+ id: typing.Any | None
17
+ identity_id: typing.Any
18
+ email: str
19
+
20
+
21
+ @dataclasses.dataclass
22
+ class EmailOTP:
23
+ id: typing.Any | None
24
+ identity_id: typing.Any
25
+ code_hash: str
26
+ expires_at: int
27
+ authentication_session_id: typing.Any
28
+
29
+ def is_expired(self) -> bool:
30
+ """
31
+ Check if the OTP has expired.
32
+
33
+ Returns:
34
+ True if the OTP has expired, False otherwise.
35
+ """
36
+ return get_current_timestamp() >= self.expires_at
37
+
38
+
39
+ class EmailOTPException(ReauthException):
40
+ """Base exception for email OTP errors."""
41
+
42
+ pass
43
+
44
+
45
+ class InvalidOTPException(EmailOTPException):
46
+ """Raised when an OTP code is invalid."""
47
+
48
+ pass
49
+
50
+
51
+ class ExpiredOTPException(EmailOTPException):
52
+ """Raised when an OTP code has expired."""
53
+
54
+ pass
55
+
56
+
57
+ class EmailOTPFactor(FactorBase, abc.ABC):
58
+ AMR: typing.ClassVar[AuthenticationMethodReference] = (
59
+ AuthenticationMethodReference.EMAIL
60
+ )
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ hash_secret: str,
66
+ code_length: int = 6,
67
+ lifetime: datetime.timedelta = datetime.timedelta(minutes=10),
68
+ min_prior_factors: int = 0,
69
+ ) -> None:
70
+ super().__init__(min_prior_factors=min_prior_factors)
71
+ self.hash_secret = hash_secret
72
+ self.code_length = code_length
73
+ self.lifetime = lifetime
74
+
75
+ async def create(
76
+ self, identity_id: typing.Any, authentication_session_id: typing.Any
77
+ ) -> tuple[str, EmailOTP]:
78
+ """
79
+ Create a new OTP for the given identity.
80
+
81
+ If an existing OTP for the same authentication session exists,
82
+ it's deleted and replaced with the new one.
83
+
84
+ Args:
85
+ identity_id: The ID of the identity to create the OTP for.
86
+ authentication_session_id: The ID of the authentication session this OTP is associated with.
87
+
88
+ Returns:
89
+ A tuple of (OTP code, EmailOTP instance).
90
+ """
91
+ await self.delete_by_authentication_session_id(authentication_session_id)
92
+
93
+ code, code_hash = generate_code_hash_pair(
94
+ secret=self.hash_secret, length=self.code_length
95
+ )
96
+ email_otp = EmailOTP(
97
+ id=None,
98
+ code_hash=code_hash,
99
+ expires_at=get_current_timestamp() + int(self.lifetime.total_seconds()),
100
+ identity_id=identity_id,
101
+ authentication_session_id=authentication_session_id,
102
+ )
103
+ email_otp.id = await self.insert(email_otp)
104
+
105
+ return code, email_otp
106
+
107
+ async def consume(self, code: str, authentication_session_id: object) -> None:
108
+ """
109
+ Consume an OTP code, deleting it from the persistent store if valid.
110
+
111
+ Args:
112
+ code: The OTP code to consume.
113
+ authentication_session_id: The ID of the authentication session this OTP is associated with.
114
+
115
+ Raises:
116
+ InvalidOTPException: If the code is invalid or does not correspond to any OTP.
117
+ ExpiredOTPException: If the OTP has expired.
118
+ """
119
+ code_hash = get_token_hash(code, secret=self.hash_secret)
120
+ email_otp = await self.get_by_code_hash_and_authentication_session_id(
121
+ code_hash, authentication_session_id
122
+ )
123
+ if email_otp is None:
124
+ raise InvalidOTPException()
125
+ if email_otp.is_expired():
126
+ raise ExpiredOTPException()
127
+ await self.delete(email_otp)
128
+
129
+ @abc.abstractmethod
130
+ async def insert(self, email_otp: EmailOTP) -> typing.Any:
131
+ """
132
+ Insert an EmailOTP instance into a persistent store.
133
+
134
+ Implementers should implement this method.
135
+
136
+ Args:
137
+ email_otp: The EmailOTP instance to insert.
138
+
139
+ Returns:
140
+ The ID of the inserted EmailOTP.
141
+ """
142
+ ...
143
+
144
+ @abc.abstractmethod
145
+ async def get_by_code_hash_and_authentication_session_id(
146
+ self, code_hash: str, authentication_session_id: typing.Any
147
+ ) -> EmailOTP | None:
148
+ """
149
+ Retrieve an EmailOTP instance by its code hash from the persistent store.
150
+
151
+ Implementers should implement this method.
152
+
153
+ Args:
154
+ code_hash: The hash of the OTP code to retrieve.
155
+ authentication_session_id: The ID of the authentication session this OTP is associated with.
156
+
157
+ Returns:
158
+ The corresponding EmailOTP instance, or None if not found.
159
+ """
160
+ ...
161
+
162
+ @abc.abstractmethod
163
+ async def update(self, email_otp: EmailOTP) -> None:
164
+ """
165
+ Update an EmailOTP instance in the persistent store.
166
+
167
+ Implementers should implement this method.
168
+
169
+ Args:
170
+ email_otp: The EmailOTP instance to update.
171
+ """
172
+ ...
173
+
174
+ @abc.abstractmethod
175
+ async def delete(self, email_otp: EmailOTP) -> None:
176
+ """
177
+ Delete an EmailOTP instance from the persistent store.
178
+
179
+ Implementers should implement this method.
180
+
181
+ Args:
182
+ email_otp: The EmailOTP instance to delete.
183
+ """
184
+ ...
185
+
186
+ @abc.abstractmethod
187
+ async def delete_by_authentication_session_id(
188
+ self, authentication_session_id: typing.Any
189
+ ) -> None:
190
+ """
191
+ Delete all EmailOTP instances associated with a given authentication session ID.
192
+
193
+ Implementers should implement this method.
194
+
195
+ Args:
196
+ authentication_session_id: The ID of the authentication session to delete OTPs for.
197
+ """
198
+ ...
reauth/factors/hotp.py ADDED
@@ -0,0 +1,164 @@
1
+ import abc
2
+ import base64
3
+ import dataclasses
4
+ import functools
5
+ import secrets
6
+ import typing
7
+
8
+ from cryptography.hazmat.primitives.hashes import SHA1
9
+ from cryptography.hazmat.primitives.twofactor import InvalidToken
10
+ from cryptography.hazmat.primitives.twofactor.hotp import HOTP as CryptoHOTP
11
+
12
+ from reauth.exceptions import ReauthException
13
+
14
+ from ..amr import AuthenticationMethodReference
15
+ from .base import FactorBase
16
+
17
+ type HOTPAlgorithm = typing.Literal["sha1"]
18
+
19
+
20
+ def _get_algorithm(algorithm: HOTPAlgorithm) -> SHA1:
21
+ match algorithm:
22
+ case "sha1":
23
+ return SHA1()
24
+
25
+
26
+ @dataclasses.dataclass
27
+ class HOTPEnrollment:
28
+ id: typing.Any | None
29
+ identity_id: typing.Any
30
+ secret: str
31
+ algorithm: HOTPAlgorithm
32
+ code_length: int
33
+ counter: int
34
+
35
+ @functools.cached_property
36
+ def _impl(self) -> CryptoHOTP:
37
+ return CryptoHOTP(
38
+ key=base64.b32decode(self.secret.encode("ascii")),
39
+ length=self.code_length,
40
+ algorithm=_get_algorithm(self.algorithm),
41
+ )
42
+
43
+ def get_provisioning_uri(
44
+ self, account_name: str, issuer_name: str | None = None
45
+ ) -> str:
46
+ """
47
+ Get the provisioning URI for this HOTP factor.
48
+
49
+ Args:
50
+ account_name: The name of the account (e.g., email or username).
51
+ issuer_name: The name of the issuer (e.g., your service name).
52
+
53
+ Returns:
54
+ The provisioning URI that can be used to generate a QR code for enrollment in an authenticator app.
55
+ """
56
+ return self._impl.get_provisioning_uri(account_name, self.counter, issuer_name)
57
+
58
+
59
+ class HOTPException(ReauthException):
60
+ """Base exception for HOTP errors."""
61
+
62
+ pass
63
+
64
+
65
+ class InvalidHOTPCodeException(HOTPException):
66
+ """Raised when an HOTP code is invalid."""
67
+
68
+ pass
69
+
70
+
71
+ class HOTPFactor(FactorBase, abc.ABC):
72
+ AMR: typing.ClassVar[AuthenticationMethodReference] = (
73
+ AuthenticationMethodReference.OTP
74
+ )
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ code_length: int = 6,
80
+ algorithm: HOTPAlgorithm = "sha1",
81
+ look_ahead: int = 5,
82
+ min_prior_factors: int = 1,
83
+ ) -> None:
84
+ super().__init__(min_prior_factors=min_prior_factors)
85
+ self.code_length = code_length
86
+ self.algorithm: HOTPAlgorithm = algorithm
87
+ self.look_ahead = look_ahead
88
+
89
+ async def enroll(self, identity_id: typing.Any) -> HOTPEnrollment:
90
+ """
91
+ Enroll a new HOTP factor for a given identity.
92
+
93
+ Args:
94
+ identity_id: The ID of the identity to enroll the factor for.
95
+
96
+ Returns:
97
+ The enrolled HOTP factor.
98
+ """
99
+ secret = secrets.token_bytes(20) # 160-bit secret key
100
+ hotp = HOTPEnrollment(
101
+ id=None,
102
+ secret=base64.b32encode(secret).decode("ascii"),
103
+ algorithm=self.algorithm,
104
+ code_length=self.code_length,
105
+ counter=0,
106
+ identity_id=identity_id,
107
+ )
108
+ hotp.id = await self.insert(hotp)
109
+ return hotp
110
+
111
+ async def verify(self, hotp: HOTPEnrollment, code: str) -> None:
112
+ """
113
+ Verify a provided OTP code against the expected value for the given HOTP factor.
114
+
115
+ On success, the counter is incremented and the HOTP factor is updated in the persistent store.
116
+
117
+ Args:
118
+ hotp: The HOTP factor to verify against.
119
+ code: The OTP code provided by the user.
120
+
121
+ Raises:
122
+ InvalidHOTPCodeException: If the provided code is invalid.
123
+ """
124
+ encoded_code = code.encode("ascii")
125
+ counter = hotp.counter
126
+
127
+ while True:
128
+ try:
129
+ hotp._impl.verify(encoded_code, counter)
130
+ break
131
+ except InvalidToken as e:
132
+ if counter - hotp.counter >= self.look_ahead:
133
+ raise InvalidHOTPCodeException() from e
134
+ counter += 1
135
+
136
+ hotp.counter = counter + 1
137
+ await self.update(hotp)
138
+
139
+ @abc.abstractmethod
140
+ async def insert(self, hotp: HOTPEnrollment) -> typing.Any:
141
+ """
142
+ Insert an HOTP factor into a persistent store.
143
+
144
+ Implementers should implement this method.
145
+
146
+ Args:
147
+ hotp: The HOTP factor to insert.
148
+
149
+ Returns:
150
+ The ID of the inserted HOTP factor.
151
+ """
152
+ ...
153
+
154
+ @abc.abstractmethod
155
+ async def update(self, hotp: HOTPEnrollment) -> None:
156
+ """
157
+ Update an HOTP factor in the persistent store.
158
+
159
+ Implementers should implement this method.
160
+
161
+ Args:
162
+ hotp: The HOTP factor to update.
163
+ """
164
+ ...
reauth/factors/totp.py ADDED
@@ -0,0 +1,174 @@
1
+ import abc
2
+ import base64
3
+ import dataclasses
4
+ import functools
5
+ import secrets
6
+ import time
7
+ import typing
8
+
9
+ from cryptography.hazmat.primitives.hashes import SHA1
10
+ from cryptography.hazmat.primitives.twofactor import InvalidToken
11
+ from cryptography.hazmat.primitives.twofactor.totp import TOTP as CryptoTOTP
12
+
13
+ from reauth.exceptions import ReauthException
14
+
15
+ from ..amr import AuthenticationMethodReference
16
+ from .base import FactorBase
17
+
18
+ type TOTPAlgorithm = typing.Literal["sha1", "sha256", "sha512"]
19
+
20
+
21
+ def _get_algorithm(algorithm: TOTPAlgorithm) -> typing.Any:
22
+ match algorithm:
23
+ case "sha1":
24
+ return SHA1()
25
+ case "sha256":
26
+ from cryptography.hazmat.primitives.hashes import SHA256
27
+
28
+ return SHA256()
29
+ case "sha512":
30
+ from cryptography.hazmat.primitives.hashes import SHA512
31
+
32
+ return SHA512()
33
+
34
+
35
+ @dataclasses.dataclass
36
+ class TOTPEnrollment:
37
+ id: typing.Any | None
38
+ identity_id: typing.Any
39
+ secret: str
40
+ algorithm: TOTPAlgorithm
41
+ code_length: int
42
+ time_step: int
43
+ last_verified_time_step: int | None = None
44
+
45
+ @functools.cached_property
46
+ def _impl(self) -> CryptoTOTP:
47
+ return CryptoTOTP(
48
+ key=base64.b32decode(self.secret.encode("ascii")),
49
+ length=self.code_length,
50
+ algorithm=_get_algorithm(self.algorithm),
51
+ time_step=self.time_step,
52
+ )
53
+
54
+ def get_provisioning_uri(
55
+ self, account_name: str, issuer_name: str | None = None
56
+ ) -> str:
57
+ """
58
+ Get the provisioning URI for this TOTP factor.
59
+
60
+ Args:
61
+ account_name: The name of the account (e.g., email or username).
62
+ issuer_name: The name of the issuer (e.g., your service name).
63
+
64
+ Returns:
65
+ The provisioning URI that can be used to generate a QR code for enrollment in an authenticator app.
66
+ """
67
+ return self._impl.get_provisioning_uri(account_name, issuer_name)
68
+
69
+
70
+ class TOTPException(ReauthException):
71
+ """Base exception for TOTP errors."""
72
+
73
+ pass
74
+
75
+
76
+ class InvalidTOTPCodeException(TOTPException):
77
+ """Raised when a TOTP code is invalid."""
78
+
79
+ pass
80
+
81
+
82
+ class TOTPFactor(FactorBase, abc.ABC):
83
+ AMR: typing.ClassVar[AuthenticationMethodReference] = (
84
+ AuthenticationMethodReference.OTP
85
+ )
86
+
87
+ def __init__(
88
+ self,
89
+ *,
90
+ code_length: int = 6,
91
+ algorithm: TOTPAlgorithm = "sha256",
92
+ time_step: int = 30,
93
+ drift_tolerance: int = 1,
94
+ min_prior_factors: int = 1,
95
+ ) -> None:
96
+ super().__init__(min_prior_factors=min_prior_factors)
97
+ self.code_length = code_length
98
+ self.algorithm: TOTPAlgorithm = algorithm
99
+ self.time_step = time_step
100
+ self.drift_tolerance = drift_tolerance
101
+
102
+ async def enroll(self, identity_id: typing.Any) -> TOTPEnrollment:
103
+ """
104
+ Enroll a new TOTP factor for a given identity.
105
+
106
+ Args:
107
+ identity_id: The ID of the identity to enroll the factor for.
108
+
109
+ Returns:
110
+ The enrolled TOTP factor.
111
+ """
112
+ secret = secrets.token_bytes(20) # 160-bit secret key
113
+ totp = TOTPEnrollment(
114
+ id=None,
115
+ secret=base64.b32encode(secret).decode("ascii"),
116
+ algorithm=self.algorithm,
117
+ code_length=self.code_length,
118
+ time_step=self.time_step,
119
+ identity_id=identity_id,
120
+ last_verified_time_step=None,
121
+ )
122
+ totp.id = await self.insert(totp)
123
+ return totp
124
+
125
+ async def verify(self, totp: TOTPEnrollment, code: str) -> None:
126
+ """
127
+ Verify a provided OTP code against the expected value for the given TOTP factor.
128
+
129
+ Args:
130
+ totp: The TOTP factor to verify against.
131
+ code: The OTP code provided by the user.
132
+
133
+ Raises:
134
+ InvalidTOTPCodeException: If the provided code is invalid or has already been used.
135
+ """
136
+ encoded_code = code.encode("ascii")
137
+ current_time = int(time.time())
138
+ drift = -self.drift_tolerance
139
+
140
+ while True:
141
+ try:
142
+ # Calculate the actual Unix time for this drift step
143
+ check_time = current_time + drift * totp.time_step
144
+ # Calculate the time step for this check
145
+ check_time_step = check_time // totp.time_step
146
+
147
+ # Replay protection: reject if this time step has already been verified
148
+ if (
149
+ totp.last_verified_time_step is not None
150
+ and check_time_step <= totp.last_verified_time_step
151
+ ):
152
+ drift += 1
153
+ continue
154
+
155
+ totp._impl.verify(encoded_code, check_time)
156
+ except InvalidToken as e:
157
+ if drift > self.drift_tolerance:
158
+ raise InvalidTOTPCodeException() from e
159
+ drift += 1
160
+ else:
161
+ # Update last verified time step and persist
162
+ totp.last_verified_time_step = check_time_step
163
+ await self.update(totp)
164
+ return
165
+
166
+ @abc.abstractmethod
167
+ async def insert(self, totp: TOTPEnrollment) -> typing.Any:
168
+ """Insert a TOTP factor into a persistent store."""
169
+ ...
170
+
171
+ @abc.abstractmethod
172
+ async def update(self, totp: TOTPEnrollment) -> None:
173
+ """Update a TOTP factor in the persistent store."""
174
+ ...
reauth/py.typed ADDED
File without changes
reauth/timestamp.py ADDED
@@ -0,0 +1,8 @@
1
+ import datetime
2
+
3
+
4
+ def get_current_timestamp() -> int:
5
+ return int(datetime.datetime.now(datetime.UTC).timestamp())
6
+
7
+
8
+ __all__ = ["get_current_timestamp"]
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: reauth
3
+ Version: 0.1.0
4
+ Summary: The authentication toolkit for Python
5
+ Project-URL: Documentation, https://frankie567.github.io/reauth/
6
+ Project-URL: Source, https://github.com/frankie567/reauth
7
+ Author-email: reauth <hello@fvoron.com>
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Python: >=3.14
12
+ Requires-Dist: anyio
13
+ Requires-Dist: cryptography<49.0.0,>=48.0.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Reauth
17
+
18
+ <p align="center">
19
+ <em>The authentication toolkit for Python</em>
20
+ </p>
21
+
22
+ [![build](https://github.com/frankie567/reauth/workflows/Build/badge.svg)](https://github.com/frankie567/reauth/actions)
23
+ [![codecov](https://codecov.io/gh/frankie567/reauth/branch/master/graph/badge.svg)](https://codecov.io/gh/frankie567/reauth)
24
+ [![PyPI version](https://badge.fury.io/py/reauth.svg)](https://badge.fury.io/py/reauth)
25
+
26
+ ---
27
+
28
+ **Documentation**: <a href="https://frankie567.github.io/reauth/" target="_blank">https://frankie567.github.io/reauth/</a>
29
+
30
+ **Source Code**: <a href="https://github.com/frankie567/reauth" target="_blank">https://github.com/frankie567/reauth</a>
31
+
32
+ ---
33
+
34
+ > [!WARNING]
35
+ > This is an early-stage project with many moving parts and mostly missing documentation. The API is not yet stable and breaking changes are expected.
36
+
37
+ ## Roadmap
38
+
39
+ Our vision is to build a comprehensive, flexible authentication toolkit for Python that handles everything from low-level factor primitives to high-level OIDC server capabilities.
40
+
41
+ ### Short-term: Core Foundation
42
+
43
+ - [ ] Factor primitives — building blocks for authentication factors
44
+ - [x] Email OTP
45
+ - [x] HOTP
46
+ - [x] TOTP
47
+ - [ ] Passwords
48
+ - [ ] Security keys
49
+ - [ ] Passkeys
50
+ - [ ] Social Login
51
+ - [x] MFA authentication management — multi-factor authentication workflows
52
+
53
+ ### Mid-term: Integration Layer
54
+
55
+ - [ ] Sessions management — robust session handling
56
+ - [ ] ORM and web frameworks wrappers — seamless integration with popular frameworks
57
+
58
+ ### Long-term: Full Platform
59
+
60
+ - [ ] OIDC server — complete OpenID Connect provider implementation
61
+ - [ ] Team management — multi-user and organizational features
62
+ - [ ] And more — expanding the ecosystem
63
+
64
+ ## Development
65
+
66
+ ### Setup environment
67
+
68
+ We use [uv](https://docs.astral.sh/uv/) to manage the development environment and production build, and [just](https://github.com/casey/just) to manage command shortcuts. Ensure they are installed on your system.
69
+
70
+ ### Run unit tests
71
+
72
+ You can run all the tests with:
73
+
74
+ ```bash
75
+ just test
76
+ ```
77
+
78
+ ### Format the code
79
+
80
+ Execute the following command to apply linting and check typing:
81
+
82
+ ```bash
83
+ just lint
84
+ ```
85
+
86
+ ### Publish a new version
87
+
88
+ You can bump the version, create a commit and associated tag with one command:
89
+
90
+ ```bash
91
+ just version patch
92
+ ```
93
+
94
+ ```bash
95
+ just version minor
96
+ ```
97
+
98
+ ```bash
99
+ just version major
100
+ ```
101
+
102
+ Your default Git text editor will open so you can add information about the release.
103
+
104
+ When you push the tag on GitHub, the workflow will automatically publish it on PyPi and a GitHub release will be created as draft.
105
+
106
+ ## Serve the documentation
107
+
108
+ You can serve the Mkdocs documentation with:
109
+
110
+ ```bash
111
+ just docs-serve
112
+ ```
113
+
114
+ It'll automatically watch for changes in your code.
115
+
116
+ ## License
117
+
118
+ This project is licensed under the terms of the MIT license.
@@ -0,0 +1,16 @@
1
+ reauth/__init__.py,sha256=hX2ohrI4CjrfoZm7GU4qQq3M8FMAsxcLUlKSxNqXa-U,67
2
+ reauth/amr.py,sha256=gffUu4AqBeQkO7YUZjAfqVsoVKAuANSCkxwUq7QNOtY,2421
3
+ reauth/authentication_session.py,sha256=-2hwF8-SyKIGoT4jOJf7e2wKJE3JyVMbdc5uVDBTvLg,7169
4
+ reauth/crypto.py,sha256=STsQ-PaiIcLqwft-yhpYvW3YoSCwkctr1F7DOGQrE_I,3635
5
+ reauth/exceptions.py,sha256=dhbRca8XkfcXspVf47mFrKGIknhiesbt1o5viL1Hr7A,229
6
+ reauth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ reauth/timestamp.py,sha256=WgSMBAH9KA7KhysU7HkQgDG7b2SKCV7bKPHFDfuggdY,156
8
+ reauth/factors/__init__.py,sha256=2SsYgK0xqpfVCddW8owL7Aio3QtynugCn7MTkOpum0k,55
9
+ reauth/factors/base.py,sha256=1WmwNgOAnSF2up8VrdLMZsTfVLm9_0qQ624rl2YKVnk,1255
10
+ reauth/factors/email_otp.py,sha256=1Tt0Ra-cS506-oWYQAzGQ3zvZdYGA5Qi_rel8hIRAVM,5768
11
+ reauth/factors/hotp.py,sha256=-uWDfGUGutCfFUYRAhjaa1wtqb-k9qmmk33vJOJIbS0,4603
12
+ reauth/factors/totp.py,sha256=fuf7_t6RVveEblx5eZ9h0Mhf3ZjiGrujKWjxD2gHfEc,5415
13
+ reauth-0.1.0.dist-info/METADATA,sha256=Q5zJKNec99VNiR9Ofay9yxRf5kJyu8g2jkpTQLoGGmw,3346
14
+ reauth-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ reauth-0.1.0.dist-info/licenses/LICENSE,sha256=nJKVyHWG779aFOFjAyk1dXYrhEdpZ9nMybIA6JsPJ7k,1074
16
+ reauth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026, François Voron
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+