Skip to content

feat(low-code): JWT string-typed iss/sub/kid, iat_backdate_seconds, and config errors for unusable keys - #1268

Merged
Daryna Ishchenko (darynaishchenko) merged 7 commits into
mainfrom
daryna/jwt-string-claims-iat-offset
Nov 9, 2026
Merged

Daryna Ishchenko (darynaishchenko) merged 7 commits into
mainfrom
daryna/jwt-string-claims-iat-offset

Conversation

@darynaishchenko

@darynaishchenko Daryna Ishchenko (darynaishchenko) commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Makes JwtAuthenticator usable for APIs whose JWT claims hold numeric-looking identifiers and for APIs that ask clients to tolerate clock drift. It also reports an unusable signing key as a config error.

Intent

  • Numeric-looking identifiers must survive interpolation as strings.
    • Many APIs identify the issuer, subject or key with an application ID, account number or key ID that is all digits, e.g. iss: "{{ config['issuer_id'] }}".
    • Interpolation literal-evaluates its result, so "111" became the int 111, and 1E23456789 became the float inf.
    • PyJWT refuses a non-string kid (Key ID header parameter must be a string). From 2.11 it also refuses a non-string iss (Issuer (iss) must be a string.). Older versions, including the locked 2.10.1, signed a numeric iss/sub as a JSON number.
    • RFC 7519/7515 define iss, sub and kid as strings, so they should be emitted as strings whatever their content.
  • Clock drift shouldn't make a fresh token look issued in the future.
    • When the connector host's clock runs slightly ahead of the API server's, an iat/nbf of "now" can be rejected as "not yet valid".
    • Some APIs (e.g. GitHub Apps) document that clients should backdate iat. Until now a manifest had no way to do that.
  • An unusable key is a config problem, and the message should say so.
    • A wrong passphrase, a truncated key, or a key of the wrong type for the algorithm used to fail with ValueError("Failed to sign token: ..."), which is reported as a system error.
    • PyJWT's text talks about a "public key" even for a private one.

Changes

  • String-typed values.

    • iss, sub, kid, the secret key and the passphrase are evaluated with valid_types=(str,), so a value that only looks numeric, boolean or float stays a string. An all-digit HMAC secret or passphrase from config used to be rejected.
    • When the config value behind iss, sub or kid is missing, the claim is left out, rather than sent as the text "None" or as a null kid that PyJWT refuses to sign. A value whose text is exactly None counts as missing, for the claims, the secret and the passphrase. An empty claim, which is what config['x'] renders for a missing key, is left out too.
    • aud is unchanged, because PyJWT also accepts a list there.
  • iat_backdate_seconds. New optional JwtAuthenticator.iat_backdate_seconds (integer, default 0, minimum 0).

    • It moves iat and nbf that many seconds into the past.
    • exp stays at now + token_duration, so the token's validity window grows by the backdate rather than ending earlier.
  • Unusable keys. The signing key is parsed through PyJWT's algorithm before signing. Any failure becomes an AirbyteTracedException with failure_type=config_error and a message naming the algorithm. This holds for every algorithm family (RSA/PSS, EC, EdDSA, HMAC) and covers:

    • a missing or empty secret,
    • a truncated key,
    • a key of the wrong type, including an EC or EdDSA key given to RS*/PS*, which PyJWT < 2.11 only rejects when it prepares the loaded key a second time,
    • an encrypted key with no or the wrong passphrase,
    • a public key, the other half of the key pair, which PyJWT accepts while preparing the key and only rejects inside jwt.encode.

    The hint in the message depends on the key: a private key gets the PEM and passphrase hints, an HMAC secret is told to check that it is set and complete. An unsupported algorithm is still a ValueError.

Backward compatibility

  • Numeric-looking iss/sub: the emitted type changes. On the locked PyJWT 2.10.1, these used to sign as JSON numbers and now sign as JSON strings, which is the RFC type.
    • Numeric-looking kid used to fail and now works.
    • I checked all 6 monorepo sources that use JwtAuthenticator: source-100ms, source-app-store-connect, source-gmail, source-google-analytics-data-api, source-google-search-console and source-google-sheets. None declares or receives a numeric-looking iss/sub: issuers are service-account emails or an Apple issuer UUID, and no source sets sub.
    • Only App Store Connect's kid can be numeric-looking, and that case used to fail.
    • Custom Builder connectors in Cloud could not be checked.
  • Missing iss/sub: the claim is left out instead of null. When the config value behind iss or sub is missing, e.g. {{ config.get('issuer') }} with no value, the token used to carry "iss": null and now carries no iss at all. A missing kid used to fail signing and now signs without it.
  • Bytes-literal secret. A secret whose text is a Python bytes literal, such as b'abc', used to be signed with the bytes abc and is now signed with the text b'abc'. No monorepo source uses one.
  • Empty HMAC secret. On PyJWT ≤ 2.10 an empty secret used to sign with an empty key, which no API should accept. It is now a config error, as it already was on PyJWT ≥ 2.11.
  • Failure type for a bad key. With direct JwtAuthenticator use, syncs that already fail on a bad key now report config_error instead of system_error, e.g. source-100ms, source-app-store-connect and Builder connectors that send the JWT as a header. No working sync changes.
    • With OAuth profile assertion (use_profile_assertion, used by source-gmail, source-google-analytics-data-api, source-google-search-console and source-google-sheets), the OAuth refresh still wraps any error as "OAuth access token refresh request failed." with system_error. The new message is only in the internal message. Letting traced exceptions through that wrapper would change error classification for every OAuth connector, so it is left for a separate PR.
  • Everything else. Other values that were already strings evaluate the same, and the new field defaults to today's behaviour.
Files
  • auth/jwt.py:
    • string-typed evaluation of the claims, the secret key and the passphrase;
    • the new field and its validation (None means 0, negative is rejected);
    • key preparation with config-error classification.
  • declarative_component_schema.yaml and models/declarative_component_schema.py: the new field.
    • The model is hand-edited with the bound written as Field(ge=0), following the convention in bin/generate_component_manifest_files.py for bounded numeric fields.
    • A regeneration with the pinned datamodel-codegen differs only by conint(ge=0).
  • parsers/model_to_component_factory.py: passes the field through.
  • Tests:
    • test_jwt.py:
      • integer-, float- and boolean-looking claims stay strings through signing and decoding;
      • a missing optional claim is left out of the signed token, including an empty one and a missing key read with config['x'];
      • an all-digit, float- or boolean-looking HMAC secret (plain and base64-encoded) and passphrase from config sign;
      • a missing optional passphrase still signs an unencrypted key;
      • the backdate moves iat/nbf but not exp, and is normalized and validated;
      • an unusable key is a config error with the hint for its algorithm family, for RS256, PS256, ES256, EdDSA and HS256: wrong-family keys in both directions and the passphrase cases, with no passphrase in either message;
      • a missing or empty secret is a config error, including with a passphrase set;
      • a public key is a config error for RS256, PS256, ES256 and EdDSA (Ed25519 and Ed448);
      • a valid key signs for every asymmetric family;
      • an unsupported algorithm is still a ValueError.
    • test_model_to_component_factory.py: the field is passed through, defaults to 0, and a negative value is rejected.

Testing

  • 52 new test cases. 44 fail on main and all pass here. The 8 that pass on main are regression guards: a plain string, the default backdate, valid keys signing for all four asymmetric families, a missing passphrase, and an unsupported algorithm.
  • The declarative auth, parsers, interpolation and requesters suites and requests_native_auth pass: 1519 tests. test_jwt.py also passes against PyJWT 2.8.0 and 2.15.1, the ends of the allowed ^2.8.0 range.
  • I probed the full factory path: a SessionTokenAuthenticator whose login_requester signs with JwtAuthenticator (RS256, issuer "111" from config, iat_backdate_seconds: 60). It signs with iss: "111" and exp - iat = token_duration + 60.

🤖 Generated with Claude Code

…seconds

Interpolation literal-evaluates its result, so an issuer, subject or key ID
read from config that only looks numeric (e.g. a GitHub App ID) became an
int and PyJWT refused to sign the token. Evaluate them with
valid_types=(str,).

Add an optional iat_backdate_seconds to JwtAuthenticator that moves iat and
nbf into the past to tolerate clock drift, as GitHub Apps recommend. exp is
unchanged and the default of 0 keeps current behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

👋 Greetings, Airbyte Team Member!

Here are some helpful tips and reminders for your convenience.

💡 Show Tips and Tricks

Testing This CDK Version

You can test this version of the CDK using the following:

# Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@daryna/jwt-string-claims-iat-offset#egg=airbyte-python-cdk[dev]' --help

# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch daryna/jwt-string-claims-iat-offset

PR Slash Commands

Airbyte Maintainers can execute the following slash commands on your PR:

  • /autofix - Fixes most formatting and linting issues
  • /poetry-lock - Updates poetry.lock file
  • /test - Runs connector tests with the updated CDK
  • /prerelease - Triggers a prerelease publish with default arguments
  • /poe build - Regenerate git-committed build artifacts, such as the pydantic models which are generated from the manifest JSON schema in YAML.
  • /poe <command> - Runs any poe command in the CDK environment
📚 Show Repo Guidance

Helpful Resources

📝 Edit this welcome message.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

PyTest Results (Fast)

6 486 tests  +180   6 450 ✅ +165   7m 3s ⏱️ + 1m 31s
    1 suites ±  0      36 💤 + 15 
    1 files   ±  0       0 ❌ ±  0 

Results for commit 2e7707b. ± Comparison against base commit e97f461.

This pull request removes 1 and adds 181 tests. Note that renamed tests count towards both.
unit_tests.test.test_scenario_sections ‑ test_unknown_section_is_rejected[incremental]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_basic_read[bypassed-incremental]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_check[bypassed-incremental]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_config_matches_spec[bypassed-incremental]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_discover[bypassed-incremental]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_docker_image_build_and_check[bypassed-incremental]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_docker_image_build_and_config_matches_spec[bypassed-incremental]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_docker_image_build_and_discover[bypassed-incremental]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_docker_image_build_and_read[bypassed-incremental]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_docker_image_build_and_read_from_future_state['invalid_config' Test Scenario]
unit_tests.resources.source_pokeapi_w_components_py.integration_tests.test_airbyte_standards.TestSuiteSourcePokeAPI ‑ test_docker_image_build_and_read_from_future_state['valid_config' Test Scenario]
…

♻️ This comment has been updated with latest results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

PyTest Results (Full)

6 489 tests   6 453 ✅  10m 56s ⏱️
    1 suites     36 💤
    1 files        0 ❌

Results for commit 2e7707b.

♻️ This comment has been updated with latest results.

PyJWT reports a private key it cannot parse as "Could not parse the
provided public key", which surfaced as a bare ValueError: not a config
error, and misleading. A key PyJWT cannot read, or a passphrase-protected
key that fails to load, now raises a config-error AirbyteTracedException
that names the private key; PyJWT's text stays in the internal message.
Other signing failures (e.g. an unsupported algorithm) still raise
ValueError as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Parse the signing key through PyJWT's own algorithm before signing, so an
  unusable key surfaces as one config error for every algorithm family. EC and
  EdDSA keys, and an encrypted key given without its passphrase, raised a bare
  ValueError before.
- Name the algorithm in the message, so a readable key of the wrong type is not
  reported as a truncated one.
- Keep emitting null for a missing optional iss/sub/kid instead of the text "None".
- Validate iat_backdate_seconds on the dataclass (None means 0, negative is rejected).
- Correct the comment about PyJWT: before 2.11 it signed non-string iss/sub as
  JSON numbers, and only a non-string kid failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@darynaishchenko Daryna Ishchenko (darynaishchenko) changed the title feat(low-code): keep JWT iss/sub/kid as strings and add iat_backdate_seconds feat(low-code): JWT string-typed iss/sub/kid, iat_backdate_seconds, and config errors for unusable keys Nov 6, 2026
- Evaluate the secret key and passphrase as strings, like iss/sub/kid, so an
  all-digit secret or passphrase from config is not turned into an int and
  rejected (and, for HMAC, no longer reported as an unusable key).
- Leave iss/sub/kid out of the token when their config value is missing,
  instead of a null that PyJWT refuses to sign for kid.
- Word the unusable-key message for HMAC secrets without the PEM hints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…et as config errors

- Prepare the loaded key object a second time inside the guard, so an EC or
  EdDSA key given to RS*/PS* is a config error on PyJWT < 2.11 too, instead of
  a TypeError raised later inside jwt.encode.
- Treat an empty secret like a missing one.
- Test the private-key hint and the missing-secret error with a passphrase set.
- Document _unusable_key_message and correct the _prepare_signing_key docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Tested locally at 02c2622: built Google Sheets, Google Search Console, and Google Analytics Data API (GA4) images with this PR's CDK and ran check using GSM service-account test credentials. All three returned SUCCEEDED, both with unchanged manifests and with iat_backdate_seconds: 60 (6/6 checks passed).

Also passed 68 focused JWT/factory unit tests, including numeric-looking claims, backdating, and invalid signing keys. No full syncs were run.


Posted by Devin, on behalf of Daryna Ishchenko (@darynaishchenko).

@tolik0 Anatolii Yatsuk (tolik0) 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.

Two cases where the code does less than the description says; neither regresses main. Both were reproduced at 02c2622 on PyJWT 2.8.0, 2.10.1 and 2.15.1, and each suggested fix was checked locally with the auth test suite.

…ty claims

A public key passed PyJWT's key preparation and only failed inside jwt.encode,
surfacing as a plain ValueError. A claim read with config['x'] on a missing key
renders as an empty string and was sent as ""; it is now left out like a
missing one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@tolik0 Anatolii Yatsuk (tolik0) 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.

Both points from my earlier review are fixed in 2e7707b: a public key in secret_key is now a config_error, and an empty iss/sub/kid from config['x'] is left out. The new tests pin each guard: removing either one makes 5 and 3 of them fail. 44 of the 52 new tests fail against base code.

@darynaishchenko
Daryna Ishchenko (darynaishchenko) marked this pull request as ready for review November 9, 2026 13:20
@darynaishchenko
Daryna Ishchenko (darynaishchenko) merged commit 39244c7 into main Nov 9, 2026
28 of 29 checks passed
@darynaishchenko
Daryna Ishchenko (darynaishchenko) deleted the daryna/jwt-string-claims-iat-offset branch November 9, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants