Repository navigation
feat(low-code): JWT string-typed iss/sub/kid, iat_backdate_seconds, and config errors for unusable keys - #1268
Conversation
…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>
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This CDK VersionYou 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-offsetPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
PyTest Results (Fast)6 486 tests +180 6 450 ✅ +165 7m 3s ⏱️ + 1m 31s 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.♻️ This comment has been updated with latest results. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PyTest Results (Full)6 489 tests 6 453 ✅ 10m 56s ⏱️ 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>
- 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>
|
Tested locally at 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). |
Anatolii Yatsuk (tolik0)
left a comment
There was a problem hiding this comment.
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>
Anatolii Yatsuk (tolik0)
left a comment
There was a problem hiding this comment.
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.
39244c7
into
main
Makes
JwtAuthenticatorusable 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
iss: "{{ config['issuer_id'] }}"."111"became the int111, and1E23456789became the floatinf.kid(Key ID header parameter must be a string). From 2.11 it also refuses a non-stringiss(Issuer (iss) must be a string.). Older versions, including the locked 2.10.1, signed a numericiss/subas a JSON number.iss,subandkidas strings, so they should be emitted as strings whatever their content.iat/nbfof "now" can be rejected as "not yet valid".iat. Until now a manifest had no way to do that.ValueError("Failed to sign token: ..."), which is reported as a system error.Changes
String-typed values.
iss,sub,kid, the secret key and the passphrase are evaluated withvalid_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.iss,suborkidis missing, the claim is left out, rather than sent as the text"None"or as a nullkidthat PyJWT refuses to sign. A value whose text is exactlyNonecounts as missing, for the claims, the secret and the passphrase. An empty claim, which is whatconfig['x']renders for a missing key, is left out too.audis unchanged, because PyJWT also accepts a list there.iat_backdate_seconds. New optionalJwtAuthenticator.iat_backdate_seconds(integer, default0, minimum0).iatandnbfthat many seconds into the past.expstays atnow + 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
AirbyteTracedExceptionwithfailure_type=config_errorand a message naming the algorithm. This holds for every algorithm family (RSA/PSS, EC, EdDSA, HMAC) and covers: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
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.kidused to fail and now works.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-lookingiss/sub: issuers are service-account emails or an Apple issuer UUID, and no source setssub.kidcan be numeric-looking, and that case used to fail.iss/sub: the claim is left out instead ofnull. When the config value behindissorsubis missing, e.g.{{ config.get('issuer') }}with no value, the token used to carry"iss": nulland now carries noissat all. A missingkidused to fail signing and now signs without it.b'abc', used to be signed with the bytesabcand is now signed with the textb'abc'. No monorepo source uses one.JwtAuthenticatoruse, syncs that already fail on a bad key now reportconfig_errorinstead ofsystem_error, e.g. source-100ms, source-app-store-connect and Builder connectors that send the JWT as a header. No working sync changes.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." withsystem_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.Files
auth/jwt.py:Nonemeans 0, negative is rejected);declarative_component_schema.yamlandmodels/declarative_component_schema.py: the new field.Field(ge=0), following the convention inbin/generate_component_manifest_files.pyfor bounded numeric fields.datamodel-codegendiffers only byconint(ge=0).parsers/model_to_component_factory.py: passes the field through.test_jwt.py:config['x'];iat/nbfbut notexp, and is normalized and validated;ValueError.test_model_to_component_factory.py: the field is passed through, defaults to 0, and a negative value is rejected.Testing
mainand all pass here. The 8 that pass onmainare regression guards: a plain string, the default backdate, valid keys signing for all four asymmetric families, a missing passphrase, and an unsupported algorithm.requests_native_authpass: 1519 tests.test_jwt.pyalso passes against PyJWT 2.8.0 and 2.15.1, the ends of the allowed^2.8.0range.SessionTokenAuthenticatorwhoselogin_requestersigns withJwtAuthenticator(RS256, issuer"111"from config,iat_backdate_seconds: 60). It signs withiss: "111"andexp - iat = token_duration + 60.🤖 Generated with Claude Code