Skip to content

Commit ef172ec

Browse files
committed
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.
1 parent c479885 commit ef172ec

6 files changed

Lines changed: 114 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
id: 1792609133-aea59846
2+
features:
3+
- globalSecurity
4+
targets:
5+
- pythonv2
6+
type: fix
7+
bump: patch
8+
description: treat empty-string credentials as unset so the environment variable fallback applies and no empty credential header or query parameter is sent
9+
author: LukasParke
10+
date: "2026-10-21"

‎templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ def get_security(security: Any, allowed_fields: Optional[List[str]] = None) -> T
4646
metadata = find_field_metadata(sec_field, SecurityMetadata)
4747
if metadata is None:
4848
continue
49+
if value == "" and metadata.sub_type not in ["basic", "custom"]:
50+
continue
4951
if metadata.option:
5052
_parse_security_option(headers, query_params, value)
5153
return headers, query_params
@@ -63,7 +65,7 @@ def get_security(security: Any, allowed_fields: Optional[List[str]] = None) -> T
6365
{{ if (and .Global.Config.EnvVarPrefix .Global.AST.MainSDK.Security) }}
6466

6567
def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseModel]:
66-
if security is not None:
68+
if security is not None and not (isinstance(security, security_class) and _is_unset(security)):
6769
return security
6870

6971
if not issubclass(security_class, BaseModel):
@@ -90,7 +92,26 @@ def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseMo
9092
security_dict["{{ sanitizeFieldName $option.Name }}"] = {{templateSecurityEnvVarValue $option}}
9193
{{ end -}}{{/* end if $option is a class */}}
9294
{{ end }}{{/* end range $option */}}
93-
return security_class(**security_dict) if security_dict else None
95+
return security_class(**security_dict) if security_dict else security
96+
97+
98+
def _is_unset(security: BaseModel) -> bool:
99+
for name, field in security.__class__.model_fields.items():
100+
value = getattr(security, name)
101+
if value is None:
102+
continue
103+
104+
metadata = find_field_metadata(field, SecurityMetadata)
105+
if metadata is None:
106+
continue
107+
if metadata.option and isinstance(value, BaseModel) and _is_unset(value):
108+
continue
109+
if value == "" and metadata.sub_type not in ["basic", "custom"]:
110+
continue
111+
112+
return False
113+
114+
return True
94115
{{ end }}{{/* end if EnvVarPrefix and global security set */}}
95116

96117
def _parse_security_option(
@@ -166,6 +187,9 @@ def _parse_security_scheme_value(
166187
scheme_type = scheme_metadata.scheme_type
167188
sub_type = scheme_metadata.sub_type
168189

190+
if value == "":
191+
return
192+
169193
header_name = security_metadata.get_field_name(field_name)
170194

171195
if scheme_type == "apiKey":

‎templates/templates/pythonv2/tests/primary/test_auth_additional.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ def test_global_security_fields_ordering():
4040
assert exc_info.value.status_code == 401
4141

4242

43+
def test_global_security_fields_ordering_skips_empty_strings():
44+
record_test("auth-global-security-fields-ordering")
45+
46+
s = SDK(
47+
server_url=HTTPBIN_URL,
48+
security=shared.Security(access_token="", api_key_auth="", oauth2="global"),
49+
)
50+
51+
res = s.auth.global_bearer_auth()
52+
assert res.http_meta is not None
53+
assert res.http_meta.response is not None
54+
assert res.http_meta.response.status_code == 200
55+
assert res.token is not None
56+
assert res.token.token == "global"
57+
58+
4359
def test_hoisted_security_access_token_only():
4460
record_test("auth-hoisted-security-access-token-only")
4561

‎templates/templates/pythonv2/tests/quaternary/test_auth_additional.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
2+
import httpx
23
from openapi import SDK, AsyncSDK
4+
from openapi.models.errors import SDKError
35
from .common_helpers import *
46
import pytest
57

@@ -59,3 +61,34 @@ async def test_global_security_flattening_env_var_fallback_async():
5961
assert res.status_code == 200
6062
finally:
6163
os.environ.pop("SPEAKEASY_API_KEY_AUTH", None)
64+
65+
66+
def test_global_security_flattening_empty_string_env_var_fallback():
67+
record_test("auth-global-security-flattening-env-var-fallback")
68+
69+
os.environ["SPEAKEASY_API_KEY_AUTH"] = "Bearer testToken"
70+
try:
71+
with SDK(api_key_auth="") as s:
72+
res = s.auth.api_key_auth_global()
73+
assert res is not None
74+
assert res.status_code == 200
75+
finally:
76+
os.environ.pop("SPEAKEASY_API_KEY_AUTH", None)
77+
78+
79+
def test_global_security_flattening_empty_string_without_env_var():
80+
record_test("auth-global-security-flattening-env-var-fallback")
81+
82+
os.environ.pop("SPEAKEASY_API_KEY_AUTH", None)
83+
captured = {}
84+
85+
def capture(request):
86+
captured["headers"] = request.headers
87+
return httpx.Response(401, request=request)
88+
89+
client = httpx.Client(transport=httpx.MockTransport(capture))
90+
with SDK(server_url=HTTPBIN_URL, api_key_auth="", client=client) as s:
91+
with pytest.raises(SDKError):
92+
s.auth.api_key_auth_global()
93+
94+
assert "authorization" not in captured["headers"]

‎zSDKs/sdk-pythonv2/.speakeasy/gen.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ def get_security(
4949
metadata = find_field_metadata(sec_field, SecurityMetadata)
5050
if metadata is None:
5151
continue
52+
if value == "" and metadata.sub_type not in ["basic", "custom"]:
53+
continue
5254
if metadata.option:
5355
_parse_security_option(headers, query_params, value)
5456
return headers, query_params
@@ -68,7 +70,9 @@ def get_security(
6870

6971

7072
def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseModel]:
71-
if security is not None:
73+
if security is not None and not (
74+
isinstance(security, security_class) and _is_unset(security)
75+
):
7276
return security
7377

7478
if not issubclass(security_class, BaseModel):
@@ -136,7 +140,26 @@ def get_security_from_env(security: Any, security_class: Any) -> Optional[BaseMo
136140
"SPEAKEASY_MY_API_KEY"
137141
)
138142

139-
return security_class(**security_dict) if security_dict else None
143+
return security_class(**security_dict) if security_dict else security
144+
145+
146+
def _is_unset(security: BaseModel) -> bool:
147+
for name, field in security.__class__.model_fields.items():
148+
value = getattr(security, name)
149+
if value is None:
150+
continue
151+
152+
metadata = find_field_metadata(field, SecurityMetadata)
153+
if metadata is None:
154+
continue
155+
if metadata.option and isinstance(value, BaseModel) and _is_unset(value):
156+
continue
157+
if value == "" and metadata.sub_type not in ["basic", "custom"]:
158+
continue
159+
160+
return False
161+
162+
return True
140163

141164

142165
def _parse_security_option(
@@ -214,6 +237,9 @@ def _parse_security_scheme_value(
214237
scheme_type = scheme_metadata.scheme_type
215238
sub_type = scheme_metadata.sub_type
216239

240+
if value == "":
241+
return
242+
217243
header_name = security_metadata.get_field_name(field_name)
218244

219245
if scheme_type == "apiKey":

0 commit comments

Comments
 (0)