Skip to content

fix(auth): enforce exp/iat claims and propagate leeway in verify_token JWKS branch - #19050

Open
phamtoanbaoquan23121985 wants to merge 2 commits into
googleapis:mainfrom
phamtoanbaoquan23121985:fix/auth-id-token-jwks-claims-leeway
Open

phamtoanbaoquan23121985 wants to merge 2 commits into
googleapis:mainfrom
phamtoanbaoquan23121985:fix/auth-id-token-jwks-claims-leeway

Conversation

@phamtoanbaoquan23121985

Copy link
Copy Markdown

Pull Request: fix(auth): enforce exp/iat claims and propagate leeway in verify_token JWKS branch

Target Branch: googleapis/google-cloud-python:main
Source Branch: fix/auth-id-token-jwks-claims-leeway
Package: packages/google-auth


Description

In google.oauth2.id_token.verify_token, when certificate keys are provided in JSON Web Key Set (JWKS) format (i.e., JSON payload containing a "keys" array), verification delegates decoding to PyJWT's jwt_lib.decode().

Prior to this PR, two discrepancies existed between the JWKS branch and the certificate-mapping (PEM) branch:

  1. Missing Claim Enforcement: options={"require": ["exp", "iat"]} was omitted in the call to jwt_lib.decode(). Consequently, tokens lacking an expiration (exp) or issued-at (iat) claim were decoded and returned without error. Under the PEM branch (google.auth.jwt.decode), missing exp or iat claims immediately raise MalformedError via _verify_iat_and_exp.
  2. Clock Skew Discarded: The parameter clock_skew_in_seconds was not passed to jwt_lib.decode(..., leeway=clock_skew_in_seconds). This caused user-configured clock skew tolerances to be silently dropped in the JWKS branch, rejecting tokens that had minor, legitimate clock drift within the configured tolerance window.

This pull request resolves both discrepancies by:

  • Explicitly specifying leeway=clock_skew_in_seconds.
  • Explicitly specifying options={"verify_signature": True, "verify_exp": True, "require": ["exp", "iat"]}.
  • Updating existing unit tests to assert the propagation of leeway and require options.
  • Adding comprehensive cryptographic differential tests validating parity between JWKS and PEM branches across 11 adversarial and boundary scenarios.

Specifications & References

  • OpenID Connect Core 1.0 (Section 3.1.3.7 - ID Token Validation):

    "The current time MUST be before the time represented by the exp Claim."
    "The iat Claim can be used to reject tokens that were issued too far away from the current time, limiting the amount of time that an attacker can use a stolen token."

  • RFC 7519 (JSON Web Token - Section 4.1.4):

    "The 'exp' (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing."


Testing & Verification

All 90 unit, async, and differential tests pass with exit code 0:

Test Suite Total Tests Passed Failed
packages/google-auth/tests/oauth2/test_id_token.py 46 46 0
packages/google-auth/tests/oauth2/test_id_token_differential.py 9 9 0
packages/google-auth/tests_async/oauth2/test_id_token.py 35 35 0
Combined Total 90 90 0

Differential Cryptographic Matrix (11 Scenarios)

Scenario JWKS (Before) JWKS (After) PEM Cert-Mapping Parity
1. Valid token (exp, iat) ACCEPTED ACCEPTED ACCEPTED ✅
2. Missing exp claim ACCEPTED (Defect) MissingRequiredClaimError MalformedError ✅
3. Missing iat claim ACCEPTED (Defect) MissingRequiredClaimError MalformedError ✅
4. Expired token ExpiredSignatureError ExpiredSignatureError InvalidValue ✅
5. Future-issued token ImmatureSignatureError ImmatureSignatureError InvalidValue ✅
6. Skew within leeway (5s expired, 10s skew) ExpiredSignatureError (Defect) ACCEPTED ACCEPTED ✅
7. Skew outside leeway (25s expired, 10s skew) ExpiredSignatureError ExpiredSignatureError InvalidValue ✅
8. Future iat within leeway (5s future, 10s skew) ImmatureSignatureError (Defect) ACCEPTED ACCEPTED ✅
9. Future iat outside leeway (25s future, 10s skew) ImmatureSignatureError ImmatureSignatureError InvalidValue ✅
10. Mismatched audience InvalidAudienceError InvalidAudienceError InvalidValue ✅
11. Tampered signature bytes DecodeError DecodeError MalformedError ✅

Checklist

  • Code follows the style guidelines of google-cloud-python.
  • Executed git diff --check with 0 whitespace errors.
  • Added unit and differential tests covering all new and modified code paths.
  • All 90 unit and async tests pass locally.
  • Asynchronous ID token verification remains unaffected (no regression).

…n JWKS branch

In `google.oauth2.id_token.verify_token`, when certificates are delivered
in JSON Web Key Set (JWKS) format (containing `"keys"`), verification is
delegated to `pyjwt`'s `jwt_lib.decode()`.

Prior to this fix:
1. `options={"require": ["exp", "iat"]}` was omitted, causing tokens lacking
   an expiration claim (`exp`) or issued-at claim (`iat`) to be accepted as
   valid without lifetime boundaries. In contrast, the certificate-mapping (PEM)
   branch strictly enforces `exp` and `iat` presence via `_verify_iat_and_exp`.
2. `leeway=clock_skew_in_seconds` was omitted, causing user-configured clock
   skew tolerance to be silently discarded in the JWKS branch, rejecting tokens
   that had acceptable time drift within the configured skew window.

This change aligns JWKS verification semantics with the certificate-mapping
branch and OpenID Connect Core 1.0 (Section 3.1.3.7) by:
- Requiring both `exp` and `iat` claims in the PyJWT options dictionary.
- Propagating `leeway=clock_skew_in_seconds` to `jwt_lib.decode()`.
- Adding unit test verification for leeway propagation and require claims.
- Adding comprehensive differential tests verifying JWKS and PEM parity.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request updates the JWK token verification path in google/oauth2/id_token.py to correctly pass leeway (configured via clock_skew_in_seconds) and validation options to jwt.decode. It also introduces comprehensive unit and differential tests to verify behavior under various token conditions. The review feedback suggests increasing the time offsets and leeway values in the clock skew tests from seconds to minutes to prevent test flakiness and loss of coverage in slow CI environments.

Comment on lines +195 to +225
def test_differential_clock_skew_expired_within_leeway(crypto_env):
now = int(time.time())
payload = {
"iss": "https://accounts.google.com",
"sub": "user-123",
"aud": "my-client-app",
"iat": now - 100,
"exp": now - 5, # expired 5 seconds ago
}
token = _make_token(crypto_env, payload)

# With 10s leeway, both must accept
crypto_env["server"].mode = "jwks"
info_jwks = id_token.verify_token(
token,
crypto_env["req"],
audience="my-client-app",
certs_url=crypto_env["server"].url,
clock_skew_in_seconds=10,
)
assert info_jwks["sub"] == "user-123"

crypto_env["server"].mode = "pem"
info_pem = id_token.verify_token(
token,
crypto_env["req"],
audience="my-client-app",
certs_url=crypto_env["server"].url,
clock_skew_in_seconds=10,
)
assert info_pem["sub"] == "user-123"

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.

medium

Using small time offsets (e.g., 5 seconds) with real system time (time.time()) can lead to test flakiness in slow CI environments. If the test runner takes more than 5 seconds to execute between generating the token and verifying it, the token will be considered fully expired even with the 10-second leeway, causing the test to fail. Increasing the time offsets and leeway (e.g., to 5 and 10 minutes) provides a robust buffer.

def test_differential_clock_skew_expired_within_leeway(crypto_env):
    now = int(time.time())
    payload = {
        "iss": "https://accounts.google.com",
        "sub": "user-123",
        "aud": "my-client-app",
        "iat": now - 1000,
        "exp": now - 300,  # expired 5 minutes ago
    }
    token = _make_token(crypto_env, payload)

    # With 10 minutes leeway, both must accept
    crypto_env["server"].mode = "jwks"
    info_jwks = id_token.verify_token(
        token,
        crypto_env["req"],
        audience="my-client-app",
        certs_url=crypto_env["server"].url,
        clock_skew_in_seconds=600,
    )
    assert info_jwks["sub"] == "user-123"

    crypto_env["server"].mode = "pem"
    info_pem = id_token.verify_token(
        token,
        crypto_env["req"],
        audience="my-client-app",
        certs_url=crypto_env["server"].url,
        clock_skew_in_seconds=600,
    )
    assert info_pem["sub"] == "user-123"

Comment on lines +228 to +259
def test_differential_clock_skew_expired_outside_leeway(crypto_env):
now = int(time.time())
payload = {
"iss": "https://accounts.google.com",
"sub": "user-123",
"aud": "my-client-app",
"iat": now - 100,
"exp": now - 20, # expired 20 seconds ago
}
token = _make_token(crypto_env, payload)

# With 10s leeway, both must reject
crypto_env["server"].mode = "jwks"
with pytest.raises(pyjwt.ExpiredSignatureError):
id_token.verify_token(
token,
crypto_env["req"],
audience="my-client-app",
certs_url=crypto_env["server"].url,
clock_skew_in_seconds=10,
)

crypto_env["server"].mode = "pem"
with pytest.raises(exceptions.InvalidValue):
id_token.verify_token(
token,
crypto_env["req"],
audience="my-client-app",
certs_url=crypto_env["server"].url,
clock_skew_in_seconds=10,
)

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.

medium

To maintain consistency with the other clock skew tests and ensure robustness against slow CI environments, increase the expiration offset and leeway values (e.g., to 10 minutes expired and 5 minutes leeway).

def test_differential_clock_skew_expired_outside_leeway(crypto_env):
    now = int(time.time())
    payload = {
        "iss": "https://accounts.google.com",
        "sub": "user-123",
        "aud": "my-client-app",
        "iat": now - 1000,
        "exp": now - 600,  # expired 10 minutes ago
    }
    token = _make_token(crypto_env, payload)

    # With 5 minutes leeway, both must reject
    crypto_env["server"].mode = "jwks"
    with pytest.raises(pyjwt.ExpiredSignatureError):
        id_token.verify_token(
            token,
            crypto_env["req"],
            audience="my-client-app",
            certs_url=crypto_env["server"].url,
            clock_skew_in_seconds=300,
        )

    crypto_env["server"].mode = "pem"
    with pytest.raises(exceptions.InvalidValue):
        id_token.verify_token(
            token,
            crypto_env["req"],
            audience="my-client-app",
            certs_url=crypto_env["server"].url,
            clock_skew_in_seconds=300,
        )

Comment on lines +261 to +291
def test_differential_clock_skew_future_iat_within_leeway(crypto_env):
now = int(time.time())
payload = {
"iss": "https://accounts.google.com",
"sub": "user-123",
"aud": "my-client-app",
"iat": now + 5, # issued 5 seconds in future
"exp": now + 3600,
}
token = _make_token(crypto_env, payload)

# With 10s leeway, both must accept
crypto_env["server"].mode = "jwks"
info_jwks = id_token.verify_token(
token,
crypto_env["req"],
audience="my-client-app",
certs_url=crypto_env["server"].url,
clock_skew_in_seconds=10,
)
assert info_jwks["sub"] == "user-123"

crypto_env["server"].mode = "pem"
info_pem = id_token.verify_token(
token,
crypto_env["req"],
audience="my-client-app",
certs_url=crypto_env["server"].url,
clock_skew_in_seconds=10,
)
assert info_pem["sub"] == "user-123"

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.

medium

Using small time offsets (e.g., 5 seconds) with real system time (time.time()) can lead to loss of test coverage in slow CI environments. If the test runner takes more than 5 seconds to execute, the iat claim will no longer be in the future, meaning the test will no longer actually verify the future iat leeway handling. Increasing the time offsets and leeway (e.g., to 5 and 10 minutes) ensures the test remains valid.

def test_differential_clock_skew_future_iat_within_leeway(crypto_env):
    now = int(time.time())
    payload = {
        "iss": "https://accounts.google.com",
        "sub": "user-123",
        "aud": "my-client-app",
        "iat": now + 300,  # issued 5 minutes in future
        "exp": now + 3600,
    }
    token = _make_token(crypto_env, payload)

    # With 10 minutes leeway, both must accept
    crypto_env["server"].mode = "jwks"
    info_jwks = id_token.verify_token(
        token,
        crypto_env["req"],
        audience="my-client-app",
        certs_url=crypto_env["server"].url,
        clock_skew_in_seconds=600,
    )
    assert info_jwks["sub"] == "user-123"

    crypto_env["server"].mode = "pem"
    info_pem = id_token.verify_token(
        token,
        crypto_env["req"],
        audience="my-client-app",
        certs_url=crypto_env["server"].url,
        clock_skew_in_seconds=600,
    )
    assert info_pem["sub"] == "user-123"

Comment on lines +294 to +325
def test_differential_clock_skew_future_iat_outside_leeway(crypto_env):
now = int(time.time())
payload = {
"iss": "https://accounts.google.com",
"sub": "user-123",
"aud": "my-client-app",
"iat": now + 20, # issued 20 seconds in future
"exp": now + 3600,
}
token = _make_token(crypto_env, payload)

# With 10s leeway, both must reject
crypto_env["server"].mode = "jwks"
with pytest.raises(pyjwt.ImmatureSignatureError):
id_token.verify_token(
token,
crypto_env["req"],
audience="my-client-app",
certs_url=crypto_env["server"].url,
clock_skew_in_seconds=10,
)

crypto_env["server"].mode = "pem"
with pytest.raises(exceptions.InvalidValue):
id_token.verify_token(
token,
crypto_env["req"],
audience="my-client-app",
certs_url=crypto_env["server"].url,
clock_skew_in_seconds=10,
)

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.

medium

Using small time offsets (e.g., 20 seconds) with real system time (time.time()) can lead to test flakiness in slow CI environments. If the test runner takes more than 10 seconds to execute, the remaining future time will fall within the 10-second leeway, causing the token to be accepted and the test to fail. Increasing the time offsets and leeway (e.g., to 10 and 5 minutes) ensures the test remains robust.

def test_differential_clock_skew_future_iat_outside_leeway(crypto_env):
    now = int(time.time())
    payload = {
        "iss": "https://accounts.google.com",
        "sub": "user-123",
        "aud": "my-client-app",
        "iat": now + 600,  # issued 10 minutes in future
        "exp": now + 3600,
    }
    token = _make_token(crypto_env, payload)

    # With 5 minutes leeway, both must reject
    crypto_env["server"].mode = "jwks"
    with pytest.raises(pyjwt.ImmatureSignatureError):
        id_token.verify_token(
            token,
            crypto_env["req"],
            audience="my-client-app",
            certs_url=crypto_env["server"].url,
            clock_skew_in_seconds=300,
        )

    crypto_env["server"].mode = "pem"
    with pytest.raises(exceptions.InvalidValue):
        id_token.verify_token(
            token,
            crypto_env["req"],
            audience="my-client-app",
            certs_url=crypto_env["server"].url,
            clock_skew_in_seconds=300,
        )

This branch has not been deployed

No deployments
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.

1 participant