diff --git a/src/auth0_server_python/auth_server/passwordless_client.py b/src/auth0_server_python/auth_server/passwordless_client.py index 7cb7911..5d4ed23 100644 --- a/src/auth0_server_python/auth_server/passwordless_client.py +++ b/src/auth0_server_python/auth_server/passwordless_client.py @@ -85,15 +85,16 @@ async def start( MissingRequiredArgumentError: When a magic link is requested but no ``redirect_uri`` is configured on the client, or ``store_options`` is not provided. + ConfigurationError: When client authentication is not configured. """ client = self._client origin_domain = await client._resolve_current_domain(store_options) body: dict[str, Any] = { "client_id": client._client_id, - "client_secret": client._client_secret, "connection": options.connection, } + client._apply_client_authentication(body, f"https://{origin_domain}/", in_body=True) if isinstance(options, StartPasswordlessEmailOptions): body["email"] = options.email @@ -194,6 +195,7 @@ async def verify( ApiError: When fetching the JWKS used to verify the ID token fails. SessionExpiredError: When the token's session-expiry ceiling is already in the past. + ConfigurationError: When client authentication is not configured. """ client = self._client origin_domain = await client._resolve_current_domain(store_options) @@ -222,12 +224,12 @@ async def verify( body: dict[str, Any] = { "grant_type": PASSWORDLESS_OTP_GRANT_TYPE, "client_id": client._client_id, - "client_secret": client._client_secret, "realm": options.connection, "username": options.username, "otp": options.verification_code, "scope": scope, } + client._apply_client_authentication(body, origin_issuer or f"https://{origin_domain}/", in_body=True) if options.audience: body["audience"] = options.audience diff --git a/src/auth0_server_python/tests/test_passwordless_client.py b/src/auth0_server_python/tests/test_passwordless_client.py index f842a88..0b57a9f 100644 --- a/src/auth0_server_python/tests/test_passwordless_client.py +++ b/src/auth0_server_python/tests/test_passwordless_client.py @@ -6,6 +6,8 @@ import jwt import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa from pydantic import ValidationError from auth0_server_python.auth_server.passwordless_client import ( @@ -20,6 +22,7 @@ VerifyPasswordlessOtpOptions, ) from auth0_server_python.error import ( + ConfigurationError, InvalidArgumentError, IssuerValidationError, MfaRequiredError, @@ -1034,3 +1037,96 @@ async def test_verify_mfa_required_without_token_falls_through(self, mocker): ) assert exc.value.code == "mfa_required" client._state_store.set.assert_not_awaited() + + +# ── Private Key JWT (client assertion) client authentication ──────────────── + + +def _generate_rsa_private_key_pem() -> str: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + + +def _public_key_pem(private_key_pem: str) -> str: + private_key = serialization.load_pem_private_key( + private_key_pem.encode("ascii"), password=None + ) + return private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + + +class TestPrivateKeyJwt: + @pytest.mark.asyncio + async def test_start_uses_private_key_jwt_assertion(self): + private_key = _generate_rsa_private_key_pem() + client = _make_client(client_secret=None, client_assertion_signing_key=private_key) + http = _mock_http(client, 200, {}) + + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + ) + + body = http.post.call_args.kwargs["json"] + assert "client_secret" not in body + assert body["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + assert len(body["client_assertion"].split(".")) == 3 + claims = jwt.decode( + body["client_assertion"], + _public_key_pem(private_key), + algorithms=["RS256"], + audience=f"https://{DOMAIN}/", + ) + assert claims["iss"] == CLIENT_ID + assert claims["sub"] == CLIENT_ID + + @pytest.mark.asyncio + async def test_verify_uses_private_key_jwt_assertion(self, mocker): + private_key = _generate_rsa_private_key_pem() + client = _make_client(client_secret=None, client_assertion_signing_key=private_key) + claims_from_id_token = {"iss": ISSUER, "sub": "auth0|1", "sid": "SID-123", "iat": 1_000} + mocker.patch.object(client, "_get_oidc_metadata_cached", return_value=METADATA) + mocker.patch.object( + client, + "_get_jwks_cached", + return_value={"keys": [{"kty": "RSA", "kid": "k1"}]}, + ) + mocker.patch.object(client, "_verify_and_decode_jwt", return_value=claims_from_id_token) + http = _mock_http( + client, + 200, + {"access_token": "at", "id_token": "idt", "expires_in": 3600, "scope": "openid"}, + ) + + await client.passwordless.verify( + VerifyPasswordlessOtpOptions( + connection="email", email="user@example.com", verification_code="123456" + ) + ) + + data = http.post.call_args.kwargs["data"] + assert "client_secret" not in data + assert data["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + assert len(data["client_assertion"].split(".")) == 3 + claims = jwt.decode( + data["client_assertion"], + _public_key_pem(private_key), + algorithms=["RS256"], + audience=ISSUER, + ) + assert claims["iss"] == CLIENT_ID + assert claims["sub"] == CLIENT_ID + + @pytest.mark.asyncio + async def test_start_no_client_auth_configured_raises_configuration_error(self): + client = _make_client(client_secret=None) + + with pytest.raises(ConfigurationError): + await client.passwordless.start( + StartPasswordlessEmailOptions(email="user@example.com", send="code") + )