Skip to content

Commit 8d487be

Browse files
committed
fix: distinguish expired ID tokens and make MFA token TTL configurable
1 parent 399485c commit 8d487be

8 files changed

Lines changed: 161 additions & 3 deletions

File tree

examples/Passwordless.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ Because a 429 that carries an explicit Auth0 `error` reports that server code, `
377377
- `invalid_grant` - the OTP is invalid, expired, or already used
378378
- `invalid_issuer` - returned ID token issuer does not match your configured Auth0 domain
379379
- `invalid_audience` - returned ID token audience does not match the SDK client
380+
- `token_expired` - the returned ID token's signature has already expired
380381
- `discovery_error` - the SDK could not load authorization server metadata
381382
- `passwordless_start_failed` - SDK-side start failure
382383
- `passwordless_verify_failed` - SDK-side verify failure

src/auth0_server_python/auth_server/mfa_client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
)
3030
from auth0_server_python.encryption.encrypt import decrypt, encrypt
3131
from auth0_server_python.error import (
32+
ConfigurationError,
3233
DomainResolverError,
3334
MfaChallengeError,
3435
MfaEnrollmentError,
@@ -71,6 +72,7 @@ def __init__(
7172
session_establisher: Optional[
7273
Callable[..., Awaitable[None]]
7374
] = None,
75+
mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL,
7476
):
7577
if callable(domain):
7678
self._domain = None
@@ -85,6 +87,9 @@ def __init__(
8587
self._state_identifier = state_identifier
8688
self._headers = headers or {}
8789
self._session_establisher = session_establisher
90+
if mfa_token_ttl <= 0:
91+
raise ConfigurationError("mfa_token_ttl must be a positive number of seconds")
92+
self._mfa_token_ttl = mfa_token_ttl
8893

8994
def _get_http_client(self, **kwargs) -> httpx.AsyncClient:
9095
"""Return an httpx.AsyncClient with default headers injected."""
@@ -142,7 +147,7 @@ def decrypt_mfa_token(self, encrypted_token: str) -> MfaTokenContext:
142147
raise MfaTokenInvalidError()
143148

144149
elapsed = int(time.time()) - context.created_at
145-
if elapsed > DEFAULT_MFA_TOKEN_TTL:
150+
if elapsed > self._mfa_token_ttl:
146151
raise MfaTokenExpiredError()
147152

148153
return context

src/auth0_server_python/auth_server/passwordless_client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,8 +421,14 @@ async def _verify_id_token(
421421
"ID token audience mismatch. Ensure your client_id is configured correctly.",
422422
e,
423423
)
424+
except jwt.ExpiredSignatureError as e:
425+
raise PasswordlessVerifyError(
426+
PasswordlessErrorCode.TOKEN_EXPIRED,
427+
f"ID token has expired: {str(e)}",
428+
e,
429+
)
424430
except jwt.InvalidTokenError as e:
425-
# Covers expired signature, bad signature, and other token defects.
431+
# Covers bad signature and other token defects.
426432
raise PasswordlessVerifyError(
427433
PasswordlessErrorCode.VERIFY_FAILED,
428434
f"ID token verification failed: {str(e)}",

src/auth0_server_python/auth_server/server_client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from pydantic import ValidationError
2121

2222
from auth0_server_python.auth_schemes.dpop_auth import make_dpop_proof_for_token_endpoint
23-
from auth0_server_python.auth_server.mfa_client import MfaClient
23+
from auth0_server_python.auth_server.mfa_client import DEFAULT_MFA_TOKEN_TTL, MfaClient
2424
from auth0_server_python.auth_server.my_account_client import MyAccountClient
2525
from auth0_server_python.auth_server.passwordless_client import PasswordlessClient
2626
from auth0_server_python.auth_types import (
@@ -125,6 +125,7 @@ def __init__(
125125
authorization_params: Optional[dict[str, Any]] = None,
126126
pushed_authorization_requests: bool = False,
127127
organization: Optional[str] = None,
128+
mfa_token_ttl: int = DEFAULT_MFA_TOKEN_TTL,
128129
):
129130
"""
130131
Initialize the Auth0 server client.
@@ -144,6 +145,13 @@ def __init__(
144145
organization: Default organization for all login flows from this client.
145146
Can be an org ID (e.g. 'org_abc123') or an org name (e.g. 'acme-corp').
146147
Per-login values passed in StartInteractiveLoginOptions always override this.
148+
mfa_token_ttl: Seconds an encrypted MFA token remains valid before
149+
`mfa.verify()`/`mfa.challenge_authenticator()` reject it as expired.
150+
Defaults to 300 (5 minutes). Increase for authenticator flows that
151+
need more time (e.g. OOB push approval on a slow connection).
152+
153+
Raises:
154+
ConfigurationError: If `mfa_token_ttl` is not a positive number of seconds.
147155
"""
148156
if not secret:
149157
raise MissingRequiredArgumentError("secret")
@@ -216,6 +224,7 @@ def __init__(
216224
state_identifier=self._state_identifier,
217225
headers=self._telemetry_headers,
218226
session_establisher=self._establish_session_from_mfa_verify_response,
227+
mfa_token_ttl=mfa_token_ttl,
219228
)
220229

221230
# Initialize Passwordless client (composes this client)
@@ -721,6 +730,8 @@ async def _establish_session_from_mfa_verify_response(
721730
raise MfaVerifyError(
722731
"ID token audience mismatch. Ensure your client_id is configured correctly."
723732
) from e
733+
except jwt.ExpiredSignatureError as e:
734+
raise MfaVerifyError(f"ID token has expired: {str(e)}") from e
724735
except jwt.InvalidTokenError as e:
725736
raise MfaVerifyError(f"ID token verification failed: {str(e)}") from e
726737

src/auth0_server_python/error/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ class PasswordlessErrorCode:
409409
INVALID_GRANT = "invalid_grant"
410410
INVALID_ISSUER = "invalid_issuer"
411411
INVALID_AUDIENCE = "invalid_audience"
412+
TOKEN_EXPIRED = "token_expired"
412413
DISCOVERY_ERROR = "discovery_error"
413414
# SDK-side
414415
START_FAILED = "passwordless_start_failed"

src/auth0_server_python/tests/test_mfa_client.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
OtpEnrollmentResponse,
1919
)
2020
from auth0_server_python.error import (
21+
ConfigurationError,
2122
DomainResolverError,
2223
MfaChallengeError,
2324
MfaEnrollmentError,
@@ -195,6 +196,34 @@ def test_decrypt_expired_token_raises(self, mocker):
195196
with pytest.raises(MfaTokenExpiredError):
196197
client.decrypt_mfa_token(encrypted)
197198

199+
def test_custom_mfa_token_ttl_is_honored(self, mocker):
200+
client = MfaClient(
201+
domain=DOMAIN,
202+
client_id=CLIENT_ID,
203+
client_secret=CLIENT_SECRET,
204+
secret=SECRET,
205+
mfa_token_ttl=10,
206+
)
207+
mocker.patch("auth0_server_python.auth_server.mfa_client.time.time", return_value=1000)
208+
encrypted = client._encrypt_mfa_token(raw_mfa_token="raw", audience="aud", scope="scope")
209+
210+
mocker.patch("auth0_server_python.auth_server.mfa_client.time.time", return_value=1005)
211+
assert client.decrypt_mfa_token(encrypted).mfa_token == "raw"
212+
213+
mocker.patch("auth0_server_python.auth_server.mfa_client.time.time", return_value=1011)
214+
with pytest.raises(MfaTokenExpiredError):
215+
client.decrypt_mfa_token(encrypted)
216+
217+
def test_non_positive_mfa_token_ttl_rejected(self):
218+
with pytest.raises(ConfigurationError):
219+
MfaClient(
220+
domain=DOMAIN,
221+
client_id=CLIENT_ID,
222+
client_secret=CLIENT_SECRET,
223+
secret=SECRET,
224+
mfa_token_ttl=0,
225+
)
226+
198227
def test_decrypt_invalid_token_raises(self):
199228
client = _make_client()
200229
with pytest.raises(MfaTokenInvalidError):

src/auth0_server_python/tests/test_passwordless_client.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
InvalidArgumentError,
2424
IssuerValidationError,
2525
MfaRequiredError,
26+
MfaVerifyError,
2627
MissingRequiredArgumentError,
2728
PasswordlessStartError,
2829
PasswordlessVerifyError,
@@ -662,6 +663,31 @@ async def test_verify_invalid_audience_maps_to_typed_error(self, mocker):
662663
assert exc.value.code == "invalid_audience"
663664
client._state_store.set.assert_not_awaited()
664665

666+
@pytest.mark.asyncio
667+
async def test_verify_expired_id_token_maps_to_typed_error(self, mocker):
668+
client = _make_client()
669+
mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA)
670+
mocker.patch.object(
671+
client,
672+
"_get_jwks_cached",
673+
return_value={"keys": [{"kty": "RSA", "kid": "k1"}]},
674+
)
675+
mocker.patch.object(
676+
client,
677+
"_verify_and_decode_jwt",
678+
side_effect=jwt.ExpiredSignatureError("signature has expired"),
679+
)
680+
_mock_http(client, 200, {"access_token": "at", "id_token": "idt", "expires_in": 3600})
681+
682+
with pytest.raises(PasswordlessVerifyError) as exc:
683+
await client.passwordless.verify(
684+
VerifyPasswordlessOtpOptions(
685+
connection="email", email="user@example.com", verification_code="123456"
686+
)
687+
)
688+
assert exc.value.code == "token_expired"
689+
client._state_store.set.assert_not_awaited()
690+
665691
@pytest.mark.asyncio
666692
async def test_client_ip_forwarded_on_verify(self, mocker):
667693
client = _make_client()
@@ -919,6 +945,60 @@ async def test_passwordless_mfa_verify_persist_creates_session(self, mocker):
919945
assert saved_state.internal.sid == "SID-MFA"
920946
assert saved_state.token_sets[0].access_token == "mfa_at"
921947

948+
@pytest.mark.asyncio
949+
async def test_mfa_verify_persist_expired_id_token_maps_to_typed_error(self, mocker):
950+
client = _make_client()
951+
mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA)
952+
mocker.patch.object(
953+
client,
954+
"_get_jwks_cached",
955+
return_value={"keys": [{"kty": "RSA", "kid": "k1"}]},
956+
)
957+
mocker.patch.object(
958+
client,
959+
"_verify_and_decode_jwt",
960+
side_effect=jwt.ExpiredSignatureError("signature has expired"),
961+
)
962+
_mock_http(
963+
client,
964+
403,
965+
{
966+
"error": "mfa_required",
967+
"error_description": "Additional factor required",
968+
"mfa_token": "raw_server_mfa_token",
969+
},
970+
)
971+
972+
with pytest.raises(MfaRequiredError) as exc:
973+
await client.passwordless.verify(
974+
VerifyPasswordlessOtpOptions(
975+
connection="email", email="user@example.com", verification_code="123456"
976+
),
977+
store_options={},
978+
)
979+
980+
client._state_store.get = AsyncMock(return_value=None)
981+
mfa_response = AsyncMock()
982+
mfa_response.status_code = 200
983+
mfa_response.headers = {}
984+
mfa_response.json = MagicMock(
985+
return_value={
986+
"access_token": "mfa_at",
987+
"id_token": "mfa_idt",
988+
"token_type": "Bearer",
989+
"expires_in": 3600,
990+
"scope": "openid profile email",
991+
}
992+
)
993+
mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mfa_response)
994+
995+
with pytest.raises(MfaVerifyError, match="expired"):
996+
await client.mfa.verify(
997+
{"mfa_token": exc.value.mfa_token, "otp": "654321", "persist": True},
998+
store_options={},
999+
)
1000+
client._state_store.set.assert_not_awaited()
1001+
9221002
@pytest.mark.asyncio
9231003
async def test_verify_mfa_required_without_token_falls_through(self, mocker):
9241004
# Third-party-strict / flex-commands-with-FF-off: 403 mfa_required with

src/auth0_server_python/tests/test_server_client.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,31 @@ async def test_init_no_secret_raises():
7979
assert "secret" in str(exc.value)
8080

8181

82+
@pytest.mark.asyncio
83+
async def test_mfa_token_ttl_propagates_to_mfa_client():
84+
"""A custom mfa_token_ttl reaches the internal MfaClient, overriding the default."""
85+
client = ServerClient(
86+
domain="auth0.local",
87+
client_id="<client_id>",
88+
client_secret="<client_secret>",
89+
secret="some-secret",
90+
mfa_token_ttl=900,
91+
)
92+
assert client._mfa_client._mfa_token_ttl == 900
93+
94+
95+
@pytest.mark.asyncio
96+
async def test_mfa_token_ttl_non_positive_rejected():
97+
with pytest.raises(ConfigurationError):
98+
ServerClient(
99+
domain="auth0.local",
100+
client_id="<client_id>",
101+
client_secret="<client_secret>",
102+
secret="some-secret",
103+
mfa_token_ttl=-1,
104+
)
105+
106+
82107
@pytest.mark.asyncio
83108
async def test_start_interactive_login_no_redirect_uri(mocker):
84109
client = ServerClient(

0 commit comments

Comments
 (0)