Skip to content

fix(python): don't send empty-string security credentials - #130

Open
LukasParke wants to merge 4 commits into
speakeasy-api:mainfrom
LukasParke:fix/python-empty-credentials
Open

LukasParke wants to merge 4 commits into
speakeasy-api:mainfrom
LukasParke:fix/python-empty-credentials

Conversation

@LukasParke

@LukasParke LukasParke commented Oct 21, 2026 •

Copy link
Copy Markdown

Why

In a generated Python SDK, a security credential passed as an empty string (for example SDK(api_key_auth="") or Security(access_token="")) was serialized as-is. For bearer, OAuth2 and OpenID Connect schemes this produced Authorization: Bearer , which httpx rejects as an illegal header value (LocalProtocolError). For API keys it sent an empty header or query parameter.

What changed

_parse_security_scheme_value in templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/security.py.stmpl returns without writing a header or query parameter when the value is "". This covers top-level fields as well as values nested in security options and scheme models.

What stays the same:

  • Scheme selection. get_security still selects the first non-None field in the declared security order, so an explicit "" keeps its scheme selected and the request is sent without a credential. It does not fall back to a later scheme.
  • Environment fallback. An explicit "" does not fall back to the envVarPrefix environment variable.
  • HTTP Basic and custom schemes. These go through _parse_basic_auth_scheme and custom handling, so an empty username or password is still sent as before (the existing auth-basic-auth-empty test covers this).
  • Only exact "" is affected. Whitespace-only strings and non-string values are not changed.

Generated output: zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/utils/security.py (plus its gen.lock checksum) is the only review SDK change. Usage snippets are not touched.

Changeset: globalSecurity / pythonv2, patch.

Testing

  • make check-template-pythonv2, npm run format, git diff --check: pass.
  • TARGET=primary make test-pythonv2: 672 passed. Adds:
    • test_global_security_fields_ordering_keeps_empty_string_selected: Security(access_token="", oauth2="global") keeps the first field selected and sends no Authorization header.
    • test_global_security_empty_ignores_env_vars: with SPEAKEASY_ACCESS_TOKEN set, both Security() and Security(access_token="") send no Authorization header.
  • TARGET=quaternary make test-pythonv2 (flattened global security with envVarPrefix): 248 passed, 1 skipped. Adds test_global_security_flattening_empty_string_ignores_env_var (SDK(api_key_auth="") with SPEAKEASY_API_KEY_AUTH set sends no Authorization header).
  • TARGET=security-options make test-pythonv2: 6 passed, 194 skipped.
  • With the template change removed, the empty-string tests fail because an Authorization header is sent. The omitted case already passes on main. It checks that the environment is not consulted when a security object is passed.
  • TARGET=review make build-pythonv2: the review SDK diff contains only the change above.
  • The new tests record the existing auth-global-security-fields-ordering and auth-global-security-flattening-env-var-fallback test IDs instead of adding new IDs, so other targets' skip lists don't need to change.
  • Not run: other targets (no template changes there) and the full make test matrix.

Public-safety check

  • This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
  • Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (.claude/skills/public-repo-communication/SKILL.md).
  • Generated fixtures and review SDK changes are public-safe.
  • I reviewed git diff --check.

Summary by cubic

Fixes generated Python SDKs so empty-string security credentials are never serialized as an empty bearer token, API key header, or query parameter, which httpx rejects.

  • Empty-string credentials are skipped when building request security, letting a later non-empty scheme win.
  • An explicitly empty global security object now stays unauthenticated; the environment fallback only applies when no security is passed at all.
  • Only exact "" counts as unset; whitespace-only values, non-string values, and HTTP Basic/custom schemes keep existing behavior.

Written for commit 90c32e0. Summary will update on new commits.

View guided diff

@github-actions github-actions Bot added the python Trigger (106) snapshot tests for python label Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

…nt 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.
@LukasParke
LukasParke force-pushed the fix/python-empty-credentials branch from d6909a8 to ef172ec Compare October 21, 2026 19:13
@LukasParke
LukasParke marked this pull request as ready for review October 21, 2026 19:19
@LukasParke
LukasParke requested a review from a team as a code owner October 21, 2026 19:19
@LukasParke
LukasParke requested a review from mfbx9da4 October 21, 2026 19:19
@LukasParke

Copy link
Copy Markdown
Author

recheck

1 similar comment
@LukasParke

Copy link
Copy Markdown
Author

recheck

@2ynn

2ynn commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

@LukasParke I don't agree with the premise of this PR. Our SDKs intentionally make a distinction between an omitted credential (e.g. SDK()) and an explicitly empty string (e.g. SDK(api_key_auth=""))

For example, a user wanting to disable security may explicitly set it empty:

sdk = SDK(security=Security())

Following your PR, environment credentials would get picked up in this case, potentially authenticating a previously unauthenticated request.

… 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.
@LukasParke LukasParke changed the title fix(python): treat empty-string credentials as unset so the environment fallback applies fix(python): don't send empty-string security credentials Oct 21, 2026
@LukasParke

Copy link
Copy Markdown
Author

@2ynn Thanks, that's a good point and I agree. An explicitly empty credential should mean "no credential", not "look in the environment".

I've removed the environment fallback in 90c32e0. get_security_from_env is back to its current behaviour, so SDK(security=Security()) and SDK(api_key_auth="") stay unauthenticated even when the envVarPrefix variables are set. There are tests for both cases with the env var set.

What's left only stops serializing empty values. Today "" on a bearer/OAuth2 scheme yields Authorization: Bearer , which httpx rejects as an illegal header value, so the call errors instead of going out unauthenticated. With this change no header (or empty API key header/query parameter) is sent, which matches TypeScript's resolveSecurity/applyBearer. get_security also skips a "" field so that a later non-empty scheme the caller passed in the same Security object is used. That's the same rule TypeScript uses to pick a security option, and it never reads the environment. Basic and custom schemes are unchanged.

@2ynn 2ynn left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today "" on a bearer/OAuth2 scheme yields Authorization: Bearer , which httpx rejects as an illegal header value, so the call errors instead of going out unauthenticated. With this change no header (or empty API key header/query parameter) is sent, which matches TypeScript's resolveSecurity/applyBearer.

I agree with that part!

Comment on lines +49 to +59
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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still disagree with skipping explicitly empty credentials during scheme selection.
Under maintainOpenAPIOrder: true (default) our SDKs preserves the security options’ declared order:

security:
  - apiKeyAuth: []
  - oauth2: []
  - clientCredentials: [read, write]
  - customSchemeAppId: []
  - basicHttp: []
  - accessToken: []
  - {}

In this example, apiKeyAuth comes first so if the caller explicitly passes api_key_auth="", that option should remain selected.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, thanks. I removed the selection change in ccf608a, so get_security matches main again. An explicit "" keeps its declared scheme selected and the request simply carries no credential; it does not fall back to a later scheme. I replaced the skipping test with test_global_security_fields_ordering_keeps_empty_string_selected, which passes Security(access_token="", oauth2="global") and asserts no Authorization header is sent.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Trigger (106) snapshot tests for python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants