Skip to content

Feat/dpop - #113

Merged
Avantol13 merged 38 commits into
masterfrom
feat/dpop
Oct 6, 2026
Merged

Avantol13 merged 38 commits into
masterfrom
feat/dpop

Conversation

@Avantol13

@Avantol13 Avantol13 commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

New Features

  • DPoP implementation for clients and servers
  • Improved JWT validation without requiring Flask as a dependency, async support
  • denylist_callback provided as a hook to validation

Breaking Changes

  • core.validate_jwt: issuers renamed to allowed_issuers, now required and
    rejected when empty
    - there is no way to skip the issuer check. aud and
    scope remain required, as before. New purpose and denylist_callback params.
  • core.validate_jwt positional slots past 5 shifted: slot 6 was options, now
    purpose; slot 7 was logger, now options. Move those to keywords.
  • Flask token.validate.validate_jwt now raises ValueError when none of
    OIDC_ISSUER/USER_API/BASE_URL is configured, instead of failing every token
    with JWTError.
  • Exception messages no longer wrap the pyjwt exception (JWTAudienceError reports
    got unknown; JWTExpiredError → "token has expired"). Don't match on text.
  • In the new async key path, a kid the issuer does not publish is an error rather
    than falling back to another key.

Bug Fixes

  • token/fastapi.py: the issuer derived from the first request leaked into every
    later request, so one token with a junk iss would 403 all subsequent valid
    tokens for the life of the process. Now per-request.
  • JWK n/e are strictly base64url-decoded. Previously a malformed JWKS silently
    produced a junk key, so every token read as "bad signature" rather than
    "issuer published a bad key".
  • keys.py handles both .well-known JWKS and legacy Fence /jwt/keys formats;
    10s timeout on key discovery; the public-key cache is mutex-guarded.
  • Updated all pre-commit tool versions (black 26.5.1 reformat).

Improvements

  • Replace httpx (no longer developed) with httpx2.
  • Updated all pre-commit tool versions

Dependency updates

  • Added joserfc >=1.7.3 (+ truststore, transitive via httpx2)
  • httpx >=0.23,<1.0 → httpx2 >=2.9.1; pyjwt >=2.4.0 → >=2.11.0

Deployment changes

  • New env var config for DPoP: DPOP_SHARED_SECRET - required by any service that mints or verifies DPoP
    nonces, MUST BE THE SAME value across ALL participating services.
  • New config for DPoP: DPOP_NONCE_TTL - optional, seconds, default 300.

@github-actions

Copy link
Copy Markdown

The style in this PR agrees with black. ✔️

This formatting comment was generated automatically by a script in uc-cdis/wool.

@coveralls

coveralls commented Aug 21, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37072616144

Coverage increased (+20.0%) to 80.283%

Details

  • Coverage increased (+20.0%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • 34 coverage regressions across 4 files.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

34 previously-covered lines in 4 files lost coverage.

File Lines Losing Coverage Coverage
authutils/token/core.py 11 88.78%
authutils/token/keys.py 11 94.44%
authutils/oauth2/client/blueprint.py 9 0.0%
authutils/globals.py 3 0.0%

Coverage Stats

Coverage Status
Relevant Lines: 989
Covered Lines: 794
Line Coverage: 80.28%
Coverage Strength: 0.8 hits per line

💛 - Coveralls

Comment thread src/authutils/dpop.py
Comment on lines +872 to +874
raise ValueError(
f"htu mismatch: request URL '{actual_url}' != proof htu '{htu_value}'"
)

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

imo it shouldn't be more robust here. b/c technically, foobar//outputs/img-1.png is a different resource than foobar/outputs/img-1.png. We tend to be forgiving in our APIs with trailing slashes, but empty resource paths are legitimately allowed and are technically different resources.

Do you know why that happened in the first place? I'd rather fix the source of accidental double-slashes than make the validation itself more flexible.

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 added an extra slash when building the key in my call to (boto3) s3_client.get_object(bucket, key)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving as-is unless you think we shouldn't. working on adding percent decoding based on feedback from the gen3 workflow PR though

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'm still a bit unsure about this one but you know dpop better so i trust your judgement

@nss10 nss10 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.

Some questions and minor change suggestions

Comment thread src/authutils/token/core.py Outdated
Comment thread src/authutils/token/core.py Outdated
f"aud must be string, list or None. Instead received aud of type {type(aud)}"
)
if not isinstance(scope, set) and not isinstance(scope, list) and not scope is None:
if not isinstance(scope, set) and not isinstance(scope, list) and scope is not None:

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.

Suggested change
if not isinstance(scope, set) and not isinstance(scope, list) and scope is not None:
if scope is not None and not isinstance(scope,(set,list)):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good call, will fix

Comment thread src/authutils/dpop.py
Comment on lines +423 to +424
# get rid of any prefixed 'DPoP ' / 'dpop '
dpop_header = _strip_auth_scheme(dpop_header)

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.

Why do it twice? Is this for future proofing?

Currently, extract_and_validate_jwk is called only once in the entire library, and the dpop_header used there has already stripped away the auth scheme.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

it's for direct callers. extract_and_validate_jwk is public for clients to call, so someone can hand it the raw DPoP eyJ... header value and it should still work. the strip up in validate_dpop_proof is needed separately b/c _verify_signature_and_claims uses dpop_header too. calling it on an already-stripped value is a no-op (returns early when there's no space), so it should be safe

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 noticed the same thing. I was going to comment that you can remove the _strip_auth_scheme call from validate_dpop_proof since it's called in extract_and_validate_jwk

Comment thread src/authutils/dpop.py Outdated
Comment on lines +805 to +807
if "exp" in claims_dict:
if current_time >= claims_dict["exp"]:
raise ValueError("Invalid DPoP proof: expired")

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.

Suggested change
if "exp" in claims_dict:
if current_time >= claims_dict["exp"]:
raise ValueError("Invalid DPoP proof: expired")
if "exp" in claims_dict and if current_time >= claims_dict["exp"]:
raise ValueError("Invalid DPoP proof: expired")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

cleaner 👍 will apply by hand b/c the suggestion has a stray if

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.

Oops! Sorry! :D

Comment thread src/authutils/dpop.py Outdated

# Normalize BOTH sides so that an explicit default port (e.g. :443 added by
# a reverse proxy) does not cause a mismatch.
actual_url = _get_url(request_url)

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.

Why a new actual_url variable? Can't we simply reuse request_url?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

actual_url isn't the same value as request_url. It's _get_url(request_url), so the query and fragment are gone, the scheme and host are lowercased, and a default :443/:80 is dropped. Both the comparison and the error message use that normalized form. We could write request_url = _get_url(request_url) instead, but then request_url would mean the raw input above that line and the normalized value below it, which is harder to follow. I'd rather keep them separate. I can rename it to normalized_request_url if that reads better

Comment thread src/authutils/dpop.py
algorithm" - which includes symetric algs not allowed.
"""

max_header_length = 4096

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.

Shouldn't this be in sync with MAX_DPOP_HEADER_LENGTH

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

they bound different things - MAX_DPOP_HEADER_LENGTH is the whole header value (header.payload.signature) checked before we parse anything, while joserfc's max_header_length only applies to the first part of the JWT (the header). so it has to be smaller. will add to the docstring so that's clear

Comment thread tests/test_keys.py Outdated
assert ticks > 5, f"event loop appears to have been blocked (ticks={ticks})"


_JWKS_URL = "https://example.com/issuer/.well-known/jwks.json"

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.

You might want to hoist this to the top of the file, since it is used above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good catch, will move it up

Comment thread tests/test_keys.py Outdated


@pytest.mark.anyio
@pytest.mark.anyio

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.

Duplicate decorator alert!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤦 will remove

Comment thread tests/test_keys.py Outdated
Comment on lines +338 to +339
result1 = await _get_key(tokens[0], allowed_issuers=issuer)
result2 = await _get_key(tokens[1], allowed_issuers=issuer)

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.

Super minor! But, result1 and result2 are never used. Maybe add another assert verifying their values? or maybe we can skip storing them altogether. maybe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

will drop these here and add a new test. these tests are about cache, and both kids share the same key material in this fixture, so asserting on the values wouldn't prove anything

Comment thread tests/test_jwt.py Outdated
Avantol13 and others added 6 commits September 30, 2026 11:47
Co-authored-by: Sai Shanmukha Narumanchi <nss10@outlook.com>
Co-authored-by: Sai Shanmukha Narumanchi <nss10@outlook.com>
Co-authored-by: Sai Shanmukha Narumanchi <nss10@outlook.com>
Comment thread tests/conftest.py Outdated
Comment thread src/authutils/token/core.py Outdated
Comment thread src/authutils/token/core.py
Comment thread src/authutils/dpop.py Outdated
Comment thread src/authutils/dpop.py
Comment thread src/authutils/dpop.py Outdated
Comment thread src/authutils/token/dpop_nonce.py
@paulineribeyre

paulineribeyre commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

I did not review the tests

Comment thread pyproject.toml
[tool.pytest.ini_options]
# Every async test runs on asyncio without a per-test mark. The library is
# asyncio-only (token/fastapi.py depends on asyncio directly).
asyncio_mode = "auto"

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.

ohh nice!

@paulineribeyre paulineribeyre 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.

lgtm, did not review the tests

@Avantol13
Avantol13 merged commit ffa4656 into master Oct 6, 2026
8 checks passed
@Avantol13
Avantol13 deleted the feat/dpop branch October 6, 2026 19:30
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.

4 participants