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/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 117f728fb..503ec512a 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/auxiliary/{{getSourcePath}}/utils/security.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl index ebd56c5e5..ed33e9a32 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl @@ -166,6 +166,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..7346631b4 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 @@ -23,21 +26,60 @@ 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 + assert captured["headers"].get("authorization") == expected_authorization + + +@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-empty") + + 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(): diff --git a/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py b/templates/templates/pythonv2/tests/quaternary/test_auth_additional.py index 5dbca397d..0a7dc19d3 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,24 @@ 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_ignores_env_var(): + record_test("auth-global-security-flattening-empty") + + captured = {} + + def capture(request): + captured["headers"] = request.headers + return httpx.Response(401, request=request) + + 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/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); } diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index cca4ec826..d0579893a 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: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 3c2775b56..fb7e6de44 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py @@ -214,6 +214,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":