-
Notifications
You must be signed in to change notification settings - Fork 4
feat: Private JWT CA support for passwordless #158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The assertion audience here is built as |
||
|
|
||
| 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 | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, and not really about this PR: these two key helpers are copied from |
||
| 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): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This pins the no-auth-configured path for start(), but verify() takes the same new |
||
| client = _make_client(client_secret=None) | ||
|
|
||
| with pytest.raises(ConfigurationError): | ||
| await client.passwordless.start( | ||
| StartPasswordlessEmailOptions(email="user@example.com", send="code") | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both this call and the one in verify() go through
_apply_client_authenticationunconditionally, so a client with no secret and no signing key now hits theConfigurationErrorthe helper raises. Passkey signin guards the same call with a check for a configured secret or key first, because it allows public clients. Before this change, start sentclient_secretas None and still reached Auth0.If passwordless is meant to always require a confidential client, this is the right behavior and worth a line in the docstring saying so. If public clients should still work here, shall we wrap the call the way passkey does? Either way it reads better as a deliberate choice than as a side effect of the helper.