From ef172ec91c550039fe49d5197946ecfbda54c3eb Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:09:41 +0000 Subject: [PATCH 1/4] fix(python): treat empty-string credentials as unset so the environment fallback applies A security value passed as an empty string (for example `SDK(api_key_auth="")`) was treated as provided: `get_security_from_env` returned early, so the `envVarPrefix` fallback never ran, and `get_security` serialized the empty value, producing headers such as `Authorization: Bearer ` that httpx rejects as an illegal header value. Empty-string credentials are now skipped when building request security, so a later non-empty scheme can be selected, and a global security object whose credentials are all empty falls back to the environment. Basic and custom schemes keep their existing behaviour, so an explicitly empty username or password is still sent. --- .changesets/1792609133-aea59846.yaml | 10 ++++++ .../{{getSourcePath}}/utils/security.py.stmpl | 28 ++++++++++++++-- .../tests/primary/test_auth_additional.py | 16 +++++++++ .../tests/quaternary/test_auth_additional.py | 33 +++++++++++++++++++ zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 2 +- .../speakeasy/new_openapi/utils/security.py | 30 +++++++++++++++-- 6 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 .changesets/1792609133-aea59846.yaml diff --git a/.changesets/1792609133-aea59846.yaml b/.changesets/1792609133-aea59846.yaml new file mode 100644 index 000000000..2e017f3a4 --- /dev/null +++ b/.changesets/1792609133-aea59846.yaml @@ -0,0 +1,10 @@ +id: 1792609133-aea59846 +features: + - globalSecurity +targets: + - pythonv2 +type: fix +bump: patch +description: treat empty-string credentials as unset so the environment variable fallback applies and no empty credential header or query parameter is sent +author: LukasParke +date: "2026-10-21" diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl index ebd56c5e5..6b2efdae1 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl @@ -46,6 +46,8 @@ def get_security(security: Any, allowed_fields: Optional[List[str]] = None) -> T metadata = find_field_metadata(sec_field, SecurityMetadata) if metadata is None: continue + if value == "" and metadata.sub_type not in ["basic", "custom"]: + continue if metadata.option: _parse_security_option(headers, query_params, value) return headers, query_params @@ -63,7 +65,7 @@ def get_security(security: Any, allowed_fields: Optional[List[str]] = None) -> T {{ if (and .Global.Config.EnvVarPrefix .Global.AST.MainSDK.Security) }} def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseModel]: - if security is not None: + if security is not None and not (isinstance(security, security_class) and _is_unset(security)): return security if not issubclass(security_class, BaseModel): @@ -90,7 +92,26 @@ def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseMo security_dict["{{ sanitizeFieldName $option.Name }}"] = {{templateSecurityEnvVarValue $option}} {{ end -}}{{/* end if $option is a class */}} {{ end }}{{/* end range $option */}} - return security_class(**security_dict) if security_dict else None + return security_class(**security_dict) if security_dict else security + + +def _is_unset(security: BaseModel) -> bool: + for name, field in security.__class__.model_fields.items(): + value = getattr(security, name) + if value is None: + continue + + metadata = find_field_metadata(field, SecurityMetadata) + if metadata is None: + continue + if metadata.option and isinstance(value, BaseModel) and _is_unset(value): + continue + if value == "" and metadata.sub_type not in ["basic", "custom"]: + continue + + return False + + return True {{ end }}{{/* end if EnvVarPrefix and global security set */}} def _parse_security_option( @@ -166,6 +187,9 @@ def _parse_security_scheme_value( scheme_type = scheme_metadata.scheme_type sub_type = scheme_metadata.sub_type + if value == "": + return + header_name = security_metadata.get_field_name(field_name) if scheme_type == "apiKey": diff --git a/templates/templates/pythonv2/tests/primary/test_auth_additional.py b/templates/templates/pythonv2/tests/primary/test_auth_additional.py index 2d851bca5..e605b7c9e 100644 --- a/templates/templates/pythonv2/tests/primary/test_auth_additional.py +++ b/templates/templates/pythonv2/tests/primary/test_auth_additional.py @@ -40,6 +40,22 @@ def test_global_security_fields_ordering(): assert exc_info.value.status_code == 401 +def test_global_security_fields_ordering_skips_empty_strings(): + record_test("auth-global-security-fields-ordering") + + s = SDK( + server_url=HTTPBIN_URL, + security=shared.Security(access_token="", api_key_auth="", oauth2="global"), + ) + + res = s.auth.global_bearer_auth() + assert res.http_meta is not None + assert res.http_meta.response is not None + assert res.http_meta.response.status_code == 200 + assert res.token is not None + assert res.token.token == "global" + + def test_hoisted_security_access_token_only(): record_test("auth-hoisted-security-access-token-only") diff --git a/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py b/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py index 5dbca397d..271458c7f 100644 --- a/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py +++ b/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py @@ -1,5 +1,7 @@ import os +import httpx from openapi import SDK, AsyncSDK +from openapi.models.errors import SDKError from .common_helpers import * import pytest @@ -59,3 +61,34 @@ async def test_global_security_flattening_env_var_fallback_async(): assert res.status_code == 200 finally: os.environ.pop("SPEAKEASY_API_KEY_AUTH", None) + + +def test_global_security_flattening_empty_string_env_var_fallback(): + record_test("auth-global-security-flattening-env-var-fallback") + + os.environ["SPEAKEASY_API_KEY_AUTH"] = "Bearer testToken" + try: + with SDK(api_key_auth="") as s: + res = s.auth.api_key_auth_global() + assert res is not None + assert res.status_code == 200 + finally: + os.environ.pop("SPEAKEASY_API_KEY_AUTH", None) + + +def test_global_security_flattening_empty_string_without_env_var(): + record_test("auth-global-security-flattening-env-var-fallback") + + os.environ.pop("SPEAKEASY_API_KEY_AUTH", None) + captured = {} + + def capture(request): + captured["headers"] = request.headers + return httpx.Response(401, request=request) + + client = httpx.Client(transport=httpx.MockTransport(capture)) + with SDK(server_url=HTTPBIN_URL, api_key_auth="", client=client) as s: + with pytest.raises(SDKError): + s.auth.api_key_auth_global() + + assert "authorization" not in captured["headers"] diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index e672ea8e3..b7447b8c9 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock @@ -776,7 +776,7 @@ trackedFiles: src/speakeasy/new_openapi/utils/retries.py: last_write_checksum: sha1:251e4dd969bb6c06025afe1bad1b5035e94c2060 src/speakeasy/new_openapi/utils/security.py: - last_write_checksum: sha1:e553de0abe14cbfcc18c6d9a710bdf364d8dd40d + last_write_checksum: sha1:95423c625b9669aa11157e1db166d81d1fe830f6 src/speakeasy/new_openapi/utils/serializers.py: last_write_checksum: sha1:24df2ceb4c7ac20b632c284c6cacf33384eda03f src/speakeasy/new_openapi/utils/unions.py: diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py index 3c2775b56..d6f26ac2c 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py @@ -49,6 +49,8 @@ def get_security( metadata = find_field_metadata(sec_field, SecurityMetadata) if metadata is None: continue + if value == "" and metadata.sub_type not in ["basic", "custom"]: + continue if metadata.option: _parse_security_option(headers, query_params, value) return headers, query_params @@ -68,7 +70,9 @@ def get_security( def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseModel]: - if security is not None: + if security is not None and not ( + isinstance(security, security_class) and _is_unset(security) + ): return security if not issubclass(security_class, BaseModel): @@ -136,7 +140,26 @@ def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseMo "SPEAKEASY_MY_API_KEY" ) - return security_class(**security_dict) if security_dict else None + return security_class(**security_dict) if security_dict else security + + +def _is_unset(security: BaseModel) -> bool: + for name, field in security.__class__.model_fields.items(): + value = getattr(security, name) + if value is None: + continue + + metadata = find_field_metadata(field, SecurityMetadata) + if metadata is None: + continue + if metadata.option and isinstance(value, BaseModel) and _is_unset(value): + continue + if value == "" and metadata.sub_type not in ["basic", "custom"]: + continue + + return False + + return True def _parse_security_option( @@ -214,6 +237,9 @@ def _parse_security_scheme_value( scheme_type = scheme_metadata.scheme_type sub_type = scheme_metadata.sub_type + if value == "": + return + header_name = security_metadata.get_field_name(field_name) if scheme_type == "apiKey": From 90c32e000fdd92f04beae729260467a000b78db1 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:31:40 +0000 Subject: [PATCH 2/4] fix(python): keep explicit empty credentials from falling back to the environment An explicitly empty credential, such as SDK(api_key_auth="") or SDK(security=Security()), now stays unauthenticated even when envVarPrefix environment variables are set. get_security_from_env is back to its previous behaviour and only consults the environment when no security was passed. Empty-string credential values are still skipped when serializing, so no "Authorization: Bearer " header or empty API key header or query parameter is sent. HTTP Basic and custom schemes are unchanged. --- .changesets/1792609133-aea59846.yaml | 10 ------- .changesets/1792625280-484bbfad.yaml | 10 +++++++ .../{{getSourcePath}}/utils/security.py.stmpl | 23 ++------------- .../tests/primary/test_auth_additional.py | 29 +++++++++++++++++++ .../tests/quaternary/test_auth_additional.py | 27 ++++++----------- zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 2 +- .../speakeasy/new_openapi/utils/security.py | 25 ++-------------- 7 files changed, 53 insertions(+), 73 deletions(-) delete mode 100644 .changesets/1792609133-aea59846.yaml create mode 100644 .changesets/1792625280-484bbfad.yaml diff --git a/.changesets/1792609133-aea59846.yaml b/.changesets/1792609133-aea59846.yaml deleted file mode 100644 index 2e017f3a4..000000000 --- a/.changesets/1792609133-aea59846.yaml +++ /dev/null @@ -1,10 +0,0 @@ -id: 1792609133-aea59846 -features: - - globalSecurity -targets: - - pythonv2 -type: fix -bump: patch -description: treat empty-string credentials as unset so the environment variable fallback applies and no empty credential header or query parameter is sent -author: LukasParke -date: "2026-10-21" diff --git a/.changesets/1792625280-484bbfad.yaml b/.changesets/1792625280-484bbfad.yaml new file mode 100644 index 000000000..7087d90dd --- /dev/null +++ b/.changesets/1792625280-484bbfad.yaml @@ -0,0 +1,10 @@ +id: 1792625280-484bbfad +features: + - globalSecurity +targets: + - pythonv2 +type: fix +bump: patch +description: stop sending empty-string credentials as an empty bearer token, API key header, or query parameter +author: LukasParke +date: "2026-10-21" diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl index 6b2efdae1..6b4e279d0 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl @@ -65,7 +65,7 @@ def get_security(security: Any, allowed_fields: Optional[List[str]] = None) -> T {{ if (and .Global.Config.EnvVarPrefix .Global.AST.MainSDK.Security) }} def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseModel]: - if security is not None and not (isinstance(security, security_class) and _is_unset(security)): + if security is not None: return security if not issubclass(security_class, BaseModel): @@ -92,26 +92,7 @@ def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseMo security_dict["{{ sanitizeFieldName $option.Name }}"] = {{templateSecurityEnvVarValue $option}} {{ end -}}{{/* end if $option is a class */}} {{ end }}{{/* end range $option */}} - return security_class(**security_dict) if security_dict else security - - -def _is_unset(security: BaseModel) -> bool: - for name, field in security.__class__.model_fields.items(): - value = getattr(security, name) - if value is None: - continue - - metadata = find_field_metadata(field, SecurityMetadata) - if metadata is None: - continue - if metadata.option and isinstance(value, BaseModel) and _is_unset(value): - continue - if value == "" and metadata.sub_type not in ["basic", "custom"]: - continue - - return False - - return True + return security_class(**security_dict) if security_dict else None {{ end }}{{/* end if EnvVarPrefix and global security set */}} def _parse_security_option( diff --git a/templates/templates/pythonv2/tests/primary/test_auth_additional.py b/templates/templates/pythonv2/tests/primary/test_auth_additional.py index e605b7c9e..fd63144db 100644 --- a/templates/templates/pythonv2/tests/primary/test_auth_additional.py +++ b/templates/templates/pythonv2/tests/primary/test_auth_additional.py @@ -1,3 +1,6 @@ +import os + +import httpx import pytest from openapi.models import shared @@ -56,6 +59,32 @@ def test_global_security_fields_ordering_skips_empty_strings(): assert res.token.token == "global" + +@pytest.mark.parametrize( + "security", + [shared.Security(), shared.Security(access_token="")], + ids=["omitted", "empty-string"], +) +def test_global_security_empty_ignores_env_vars(security): + record_test("auth-global-security-fields-ordering") + + captured = {} + + def capture(request): + captured["headers"] = request.headers + return httpx.Response(401, request=request) + + os.environ["SPEAKEASY_ACCESS_TOKEN"] = "global" + try: + client = httpx.Client(transport=httpx.MockTransport(capture)) + s = SDK(server_url=HTTPBIN_URL, security=security, client=client) + with pytest.raises(APIError): + s.auth.global_bearer_auth() + finally: + os.environ.pop("SPEAKEASY_ACCESS_TOKEN", None) + + assert "authorization" not in captured["headers"] + def test_hoisted_security_access_token_only(): record_test("auth-hoisted-security-access-token-only") diff --git a/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py b/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py index 271458c7f..92771f245 100644 --- a/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py +++ b/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py @@ -63,32 +63,23 @@ async def test_global_security_flattening_env_var_fallback_async(): os.environ.pop("SPEAKEASY_API_KEY_AUTH", None) -def test_global_security_flattening_empty_string_env_var_fallback(): - record_test("auth-global-security-flattening-env-var-fallback") - - os.environ["SPEAKEASY_API_KEY_AUTH"] = "Bearer testToken" - try: - with SDK(api_key_auth="") as s: - res = s.auth.api_key_auth_global() - assert res is not None - assert res.status_code == 200 - finally: - os.environ.pop("SPEAKEASY_API_KEY_AUTH", None) - -def test_global_security_flattening_empty_string_without_env_var(): +def test_global_security_flattening_empty_string_ignores_env_var(): record_test("auth-global-security-flattening-env-var-fallback") - os.environ.pop("SPEAKEASY_API_KEY_AUTH", None) captured = {} def capture(request): captured["headers"] = request.headers return httpx.Response(401, request=request) - client = httpx.Client(transport=httpx.MockTransport(capture)) - with SDK(server_url=HTTPBIN_URL, api_key_auth="", client=client) as s: - with pytest.raises(SDKError): - s.auth.api_key_auth_global() + os.environ["SPEAKEASY_API_KEY_AUTH"] = "Bearer testToken" + try: + client = httpx.Client(transport=httpx.MockTransport(capture)) + with SDK(server_url=HTTPBIN_URL, api_key_auth="", client=client) as s: + with pytest.raises(SDKError): + s.auth.api_key_auth_global() + finally: + os.environ.pop("SPEAKEASY_API_KEY_AUTH", None) assert "authorization" not in captured["headers"] diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index b7447b8c9..579ff471d 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock @@ -776,7 +776,7 @@ trackedFiles: src/speakeasy/new_openapi/utils/retries.py: last_write_checksum: sha1:251e4dd969bb6c06025afe1bad1b5035e94c2060 src/speakeasy/new_openapi/utils/security.py: - last_write_checksum: sha1:95423c625b9669aa11157e1db166d81d1fe830f6 + last_write_checksum: sha1:d829a0ab3f38d2d9272726277c0667d63f3471c1 src/speakeasy/new_openapi/utils/serializers.py: last_write_checksum: sha1:24df2ceb4c7ac20b632c284c6cacf33384eda03f src/speakeasy/new_openapi/utils/unions.py: diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py index d6f26ac2c..fca93c100 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py @@ -70,9 +70,7 @@ def get_security( def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseModel]: - if security is not None and not ( - isinstance(security, security_class) and _is_unset(security) - ): + if security is not None: return security if not issubclass(security_class, BaseModel): @@ -140,26 +138,7 @@ def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseMo "SPEAKEASY_MY_API_KEY" ) - return security_class(**security_dict) if security_dict else security - - -def _is_unset(security: BaseModel) -> bool: - for name, field in security.__class__.model_fields.items(): - value = getattr(security, name) - if value is None: - continue - - metadata = find_field_metadata(field, SecurityMetadata) - if metadata is None: - continue - if metadata.option and isinstance(value, BaseModel) and _is_unset(value): - continue - if value == "" and metadata.sub_type not in ["basic", "custom"]: - continue - - return False - - return True + return security_class(**security_dict) if security_dict else None def _parse_security_option( From ccf608a2c72e5f3d088aff5cfc253573021deac5 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:22:27 +0000 Subject: [PATCH 3/4] fix(python): keep an explicitly empty credential's scheme selected Scheme selection in get_security again matches the declared security order: a field set to "" stays selected like any other non-None value instead of being skipped in favour of a later scheme. The empty value is still not serialized, so no credential is sent. Replace the selection-skipping test with one asserting that an empty first scheme stays selected and no Authorization header is sent. --- .../{{getSourcePath}}/utils/security.py.stmpl | 2 -- .../tests/primary/test_auth_additional.py | 23 +++++++++++-------- .../tests/quaternary/test_auth_additional.py | 1 - zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 2 +- .../speakeasy/new_openapi/utils/security.py | 2 -- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl index 6b4e279d0..ed33e9a32 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl @@ -46,8 +46,6 @@ def get_security(security: Any, allowed_fields: Optional[List[str]] = None) -> T metadata = find_field_metadata(sec_field, SecurityMetadata) if metadata is None: continue - if value == "" and metadata.sub_type not in ["basic", "custom"]: - continue if metadata.option: _parse_security_option(headers, query_params, value) return headers, query_params diff --git a/templates/templates/pythonv2/tests/primary/test_auth_additional.py b/templates/templates/pythonv2/tests/primary/test_auth_additional.py index fd63144db..13f23a3ac 100644 --- a/templates/templates/pythonv2/tests/primary/test_auth_additional.py +++ b/templates/templates/pythonv2/tests/primary/test_auth_additional.py @@ -43,21 +43,25 @@ def test_global_security_fields_ordering(): assert exc_info.value.status_code == 401 -def test_global_security_fields_ordering_skips_empty_strings(): +def test_global_security_fields_ordering_keeps_empty_string_selected(): record_test("auth-global-security-fields-ordering") + captured = {} + + def capture(request): + captured["headers"] = request.headers + return httpx.Response(401, request=request) + + client = httpx.Client(transport=httpx.MockTransport(capture)) s = SDK( server_url=HTTPBIN_URL, - security=shared.Security(access_token="", api_key_auth="", oauth2="global"), + security=shared.Security(access_token="", oauth2="global"), + client=client, ) + with pytest.raises(APIError): + s.auth.global_bearer_auth() - res = s.auth.global_bearer_auth() - assert res.http_meta is not None - assert res.http_meta.response is not None - assert res.http_meta.response.status_code == 200 - assert res.token is not None - assert res.token.token == "global" - + assert "authorization" not in captured["headers"] @pytest.mark.parametrize( @@ -85,6 +89,7 @@ def capture(request): assert "authorization" not in captured["headers"] + def test_hoisted_security_access_token_only(): record_test("auth-hoisted-security-access-token-only") diff --git a/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py b/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py index 92771f245..b5f072a2e 100644 --- a/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py +++ b/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py @@ -63,7 +63,6 @@ async def test_global_security_flattening_env_var_fallback_async(): os.environ.pop("SPEAKEASY_API_KEY_AUTH", None) - def test_global_security_flattening_empty_string_ignores_env_var(): record_test("auth-global-security-flattening-env-var-fallback") diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index 579ff471d..59e60de0a 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock @@ -776,7 +776,7 @@ trackedFiles: src/speakeasy/new_openapi/utils/retries.py: last_write_checksum: sha1:251e4dd969bb6c06025afe1bad1b5035e94c2060 src/speakeasy/new_openapi/utils/security.py: - last_write_checksum: sha1:d829a0ab3f38d2d9272726277c0667d63f3471c1 + last_write_checksum: sha1:e58034e6e574abe61237f9cfc31cf18e2a0f064b src/speakeasy/new_openapi/utils/serializers.py: last_write_checksum: sha1:24df2ceb4c7ac20b632c284c6cacf33384eda03f src/speakeasy/new_openapi/utils/unions.py: diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py index fca93c100..fb7e6de44 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py @@ -49,8 +49,6 @@ def get_security( metadata = find_field_metadata(sec_field, SecurityMetadata) if metadata is None: continue - if value == "" and metadata.sub_type not in ["basic", "custom"]: - continue if metadata.option: _parse_security_option(headers, query_params, value) return headers, query_params From 96b2d97e4c9c513ae554bbbd58eb6ffe69e05a5f Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:23:30 +0000 Subject: [PATCH 4/4] test(python): parametrize global security ordering test and register empty-credential test IDs Fold the explicit-empty-string case into test_global_security_fields_ordering instead of a separate test, and assert which Authorization header is sent. Register auth-global-security-empty and auth-global-security-flattening-empty for the empty-credential tests, which previously reused unrelated IDs. Other targets skip both IDs. --- internal/features/tests.go | 2 + internal/features/tests_generated.go | 10 +++++ templates/templates/cli/features.ts | 2 + templates/templates/csharp/features.ts | 2 + templates/templates/go/features.ts | 2 + templates/templates/javav2/features.ts | 2 + .../templates/mcp-typescript/features.ts | 2 + templates/templates/php/features.ts | 2 + .../tests/primary/test_auth_additional.py | 40 ++++++++----------- .../tests/quaternary/test_auth_additional.py | 2 +- templates/templates/ruby/features.ts | 2 + templates/templates/typescriptv2/features.ts | 2 + templates/templates/unity/features.ts | 2 + 13 files changed, 47 insertions(+), 25 deletions(-) diff --git a/internal/features/tests.go b/internal/features/tests.go index a4031fcb5..5cbc91edd 100644 --- a/internal/features/tests.go +++ b/internal/features/tests.go @@ -42,6 +42,7 @@ const ( TestAuthBearerAuthOperationWithPrefix TestAuthBearerAuthOperationWithoutPrefix TestAuthGlobalSecurityFieldsOrdering + TestAuthGlobalSecurityEmpty TestAuthGlobalSecurityOptionFieldsOrdering TestAuthOauth2Auth TestAuthOpenIDConnectAuth @@ -58,6 +59,7 @@ const ( TestAuthGlobalSecurityFlattening TestAuthGlobalSecurityFlatteningCallback TestAuthGlobalSecurityFlatteningEnvVarFallback + TestAuthGlobalSecurityFlatteningEmpty TestAuthHoistedOperationAuthRetained TestAuthCustomSecurityOptionAppId TestAuthCustomSecuritySchemeAppId diff --git a/internal/features/tests_generated.go b/internal/features/tests_generated.go index b6dd807de..80906943f 100644 --- a/internal/features/tests_generated.go +++ b/internal/features/tests_generated.go @@ -36,12 +36,16 @@ func (t Test) String() string { return "auth-custom-security-scheme-only" case TestAuthFunctionCallbacksOauthGlobalSecurity: return "auth-function-callbacks-oauth-global-security" + case TestAuthGlobalSecurityEmpty: + return "auth-global-security-empty" case TestAuthGlobalSecurityFieldsOrdering: return "auth-global-security-fields-ordering" case TestAuthGlobalSecurityFlattening: return "auth-global-security-flattening" case TestAuthGlobalSecurityFlatteningCallback: return "auth-global-security-flattening-callback" + case TestAuthGlobalSecurityFlatteningEmpty: + return "auth-global-security-flattening-empty" case TestAuthGlobalSecurityFlatteningEnvVarFallback: return "auth-global-security-flattening-env-var-fallback" case TestAuthGlobalSecurityOptionFieldsOrdering: @@ -1556,12 +1560,16 @@ func TestFromString(s string) Test { return TestAuthCustomSecuritySchemeOnly case "auth-function-callbacks-oauth-global-security": return TestAuthFunctionCallbacksOauthGlobalSecurity + case "auth-global-security-empty": + return TestAuthGlobalSecurityEmpty case "auth-global-security-fields-ordering": return TestAuthGlobalSecurityFieldsOrdering case "auth-global-security-flattening": return TestAuthGlobalSecurityFlattening case "auth-global-security-flattening-callback": return TestAuthGlobalSecurityFlatteningCallback + case "auth-global-security-flattening-empty": + return TestAuthGlobalSecurityFlatteningEmpty case "auth-global-security-flattening-env-var-fallback": return TestAuthGlobalSecurityFlatteningEnvVarFallback case "auth-global-security-option-fields-ordering": @@ -3059,9 +3067,11 @@ var testList = []Test{ TestAuthCustomSecuritySchemeAppId, TestAuthCustomSecuritySchemeOnly, TestAuthFunctionCallbacksOauthGlobalSecurity, + TestAuthGlobalSecurityEmpty, TestAuthGlobalSecurityFieldsOrdering, TestAuthGlobalSecurityFlattening, TestAuthGlobalSecurityFlatteningCallback, + TestAuthGlobalSecurityFlatteningEmpty, TestAuthGlobalSecurityFlatteningEnvVarFallback, TestAuthGlobalSecurityOptionFieldsOrdering, TestAuthHoistedBasicAuth, diff --git a/templates/templates/cli/features.ts b/templates/templates/cli/features.ts index a0c382e38..03632c8a9 100644 --- a/templates/templates/cli/features.ts +++ b/templates/templates/cli/features.ts @@ -359,6 +359,8 @@ function isTestSkipped(test: string): boolean { "parameters-path-parameter-formats", "shared-enum-constructor-default", "open-union-invalid-json-is-an-error", + "auth-global-security-empty", + "auth-global-security-flattening-empty", ].includes(test); } diff --git a/templates/templates/csharp/features.ts b/templates/templates/csharp/features.ts index 8c3bd71eb..49e965850 100644 --- a/templates/templates/csharp/features.ts +++ b/templates/templates/csharp/features.ts @@ -430,6 +430,8 @@ function isTestSkipped(test: string): boolean { "event-stream-absent-required-union", "event-stream-equivalent-number-lexemes", "event-stream-null-and-undeclared-fields", + "auth-global-security-empty", + "auth-global-security-flattening-empty", ].includes(test); } diff --git a/templates/templates/go/features.ts b/templates/templates/go/features.ts index 1283baf4b..3cb52bb20 100644 --- a/templates/templates/go/features.ts +++ b/templates/templates/go/features.ts @@ -427,6 +427,8 @@ function isTestSkipped(test: string): boolean { "event-stream-absent-required-union", "event-stream-equivalent-number-lexemes", "event-stream-null-and-undeclared-fields", + "auth-global-security-empty", + "auth-global-security-flattening-empty", ].includes(test); } diff --git a/templates/templates/javav2/features.ts b/templates/templates/javav2/features.ts index 3e30be165..cb17f04bb 100644 --- a/templates/templates/javav2/features.ts +++ b/templates/templates/javav2/features.ts @@ -478,6 +478,8 @@ function isTestSkipped(test: string): boolean { "event-stream-absent-required-union", "event-stream-equivalent-number-lexemes", "event-stream-null-and-undeclared-fields", + "auth-global-security-empty", + "auth-global-security-flattening-empty", ].includes(test); } diff --git a/templates/templates/mcp-typescript/features.ts b/templates/templates/mcp-typescript/features.ts index fe3e88b41..b37eeb3af 100644 --- a/templates/templates/mcp-typescript/features.ts +++ b/templates/templates/mcp-typescript/features.ts @@ -687,6 +687,8 @@ function isTestSkipped(test: string): boolean { "event-stream-absent-required-union", "event-stream-equivalent-number-lexemes", "event-stream-null-and-undeclared-fields", + "auth-global-security-empty", + "auth-global-security-flattening-empty", ].includes(test); } diff --git a/templates/templates/php/features.ts b/templates/templates/php/features.ts index 0254cdade..7d36aa892 100644 --- a/templates/templates/php/features.ts +++ b/templates/templates/php/features.ts @@ -572,6 +572,8 @@ function isTestSkipped(test: string): boolean { "event-stream-absent-required-union", "event-stream-equivalent-number-lexemes", "event-stream-null-and-undeclared-fields", + "auth-global-security-empty", + "auth-global-security-flattening-empty", ].includes(test); } diff --git a/templates/templates/pythonv2/tests/primary/test_auth_additional.py b/templates/templates/pythonv2/tests/primary/test_auth_additional.py index 13f23a3ac..7346631b4 100644 --- a/templates/templates/pythonv2/tests/primary/test_auth_additional.py +++ b/templates/templates/pythonv2/tests/primary/test_auth_additional.py @@ -26,42 +26,34 @@ def test_function_callbacks_for_o_auth_support_global_security(): assert res.token.token == "global" -def test_global_security_fields_ordering(): +@pytest.mark.parametrize( + "api_key_auth,expected_authorization", + [("testApiKey", "testApiKey"), ("", None)], + ids=["populated", "empty-string"], +) +def test_global_security_fields_ordering(api_key_auth, expected_authorization): record_test("auth-global-security-fields-ordering") + captured = {} + + def capture(request): + captured["headers"] = request.headers + # When maintainOpenApiOrder: false is set, the first non-nil field (in alphabetical order) is selected. # In this case api_key_auth takes precedence over basic_http, which is invalid for this endpoint. + # An explicitly empty api_key_auth stays selected and sends no credential. s = SDK( security=shared.Security( - api_key_auth="testApiKey", + api_key_auth=api_key_auth, basic_http=shared.SchemeBasicHTTP(username="testUser", password="testPass"), ), + client=httpx.Client(event_hooks={"request": [capture]}), ) with pytest.raises(APIError) as exc_info: s.auth.global_security_basic_http() assert exc_info.value.status_code == 401 - - -def test_global_security_fields_ordering_keeps_empty_string_selected(): - record_test("auth-global-security-fields-ordering") - - captured = {} - - def capture(request): - captured["headers"] = request.headers - return httpx.Response(401, request=request) - - client = httpx.Client(transport=httpx.MockTransport(capture)) - s = SDK( - server_url=HTTPBIN_URL, - security=shared.Security(access_token="", oauth2="global"), - client=client, - ) - with pytest.raises(APIError): - s.auth.global_bearer_auth() - - assert "authorization" not in captured["headers"] + assert captured["headers"].get("authorization") == expected_authorization @pytest.mark.parametrize( @@ -70,7 +62,7 @@ def capture(request): ids=["omitted", "empty-string"], ) def test_global_security_empty_ignores_env_vars(security): - record_test("auth-global-security-fields-ordering") + record_test("auth-global-security-empty") captured = {} diff --git a/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py b/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py index b5f072a2e..0a7dc19d3 100644 --- a/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py +++ b/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py @@ -64,7 +64,7 @@ async def test_global_security_flattening_env_var_fallback_async(): def test_global_security_flattening_empty_string_ignores_env_var(): - record_test("auth-global-security-flattening-env-var-fallback") + record_test("auth-global-security-flattening-empty") captured = {} diff --git a/templates/templates/ruby/features.ts b/templates/templates/ruby/features.ts index 3435fe0de..29737aae1 100644 --- a/templates/templates/ruby/features.ts +++ b/templates/templates/ruby/features.ts @@ -475,6 +475,8 @@ function isTestSkipped(test: string): boolean { "event-stream-absent-required-union", "event-stream-equivalent-number-lexemes", "event-stream-null-and-undeclared-fields", + "auth-global-security-empty", + "auth-global-security-flattening-empty", ].includes(test); } diff --git a/templates/templates/typescriptv2/features.ts b/templates/templates/typescriptv2/features.ts index becfb3d96..140298c5e 100644 --- a/templates/templates/typescriptv2/features.ts +++ b/templates/templates/typescriptv2/features.ts @@ -423,6 +423,8 @@ function isTestSkipped(test: string): boolean { "event-stream-absent-required-union", "event-stream-equivalent-number-lexemes", "event-stream-null-and-undeclared-fields", + "auth-global-security-empty", + "auth-global-security-flattening-empty", ].includes(test); } diff --git a/templates/templates/unity/features.ts b/templates/templates/unity/features.ts index e69b828ac..d9ac2a8cb 100644 --- a/templates/templates/unity/features.ts +++ b/templates/templates/unity/features.ts @@ -596,6 +596,8 @@ function isTestSkipped(test: string): boolean { "event-stream-absent-required-union", "event-stream-equivalent-number-lexemes", "event-stream-null-and-undeclared-fields", + "auth-global-security-empty", + "auth-global-security-flattening-empty", ].includes(test); }