Skip to content

Feat/dpop - #1372

Open
Avantol13 wants to merge 51 commits into
masterfrom
feat/dpop
Open

Avantol13 wants to merge 51 commits into
masterfrom
feat/dpop

Conversation

@Avantol13

@Avantol13 Avantol13 commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

New Features

  • Optional DPoP (RFC 9449) support for task tokens: when DPOP_ENABLED is true, POST /credentials/api/access_token requires a valid DPoP proof (with nonce) for task token requests and issues a key-bound access token with a cnf.jkt claim.
  • DPoP nonce challenge flow: an invalid or missing nonce returns the RFC 9449 error response plus a fresh DPoP-Nonce header so the client can retry.
  • New DPOP_SHARED_SECRET, which must be identical across all Gen3 services that participate in DPoP. DPOP_SHARED_SECRET can be supplied via environment variable

Breaking Changes

  • If DPoP is enabled on, Task Tokens now require DPoP flow and issues DPoP-Bound Task Tokens

Bug Fixes

  • validate_jwt no longer performs JWKS key discovery against an unverified iss: the issuer is checked against the configured allowlist first, and requests with no configured issuers are rejected instead of triggering an outbound request.

Improvements

  • Tests for DPoP-bound token issuance on /credentials/api/access_token (enabled/disabled, mi retry) and for the issuer allowlist check in validate_jwt.

Dependency updates

  • authutils to get DPoP support

Deployment changes

  • DB MIGRATION REQUIRED for DPoP!
  • To use DPoP Task Tokens, set DPOP_ENABLED: true and set DPOP_SHARED_SECRET (e.g. openssl rand -base64 64) to the same value in every service doing DPoP validation. Fence will refuse to start if DPoP is enabled without the secret.

Avantol13 added 30 commits July 22, 2026 14:09
… may be based on internal k8s routing to the service
@github-actions

Copy link
Copy Markdown

Integration Tests

Failed to Prepare CI environment

Please find the Github Action logs here

@github-actions

Copy link
Copy Markdown

Integration Tests

filepath passed skipped SUBTOTAL
tests/test_oauth2.py 15 0 15
tests/test_drs_endpoint.py 22 3 25
tests/test_centralized_auth.py 16 0 16
tests/test_audit_service.py 3 3 6
tests/test_data_upload.py 8 1 9
tests/test_presigned_url.py 8 0 8
tests/test_dbgap.py 4 1 5
tests/test_user_token.py 5 0 5
tests/test_user_login_activation.py 2 1 3
tests/test_fence_admin.py 2 0 2
tests/test_register_user.py 2 0 2
tests/test_oidc_client.py 2 0 2
tests/test_client_credentials.py 1 0 1
tests/test_google_data_access.py 1 0 1
tests/test_ras_authn.py 0 3 3
tests/test_ras_passport.py 0 3 3
TOTAL 91 15 106

Please find the detailed integration test report here

Please find the Github Action logs here

@github-actions

Copy link
Copy Markdown

Integration Tests

filepath passed skipped SUBTOTAL
tests/test_oauth2.py 15 0 15
tests/test_drs_endpoint.py 22 3 25
tests/test_centralized_auth.py 16 0 16
tests/test_audit_service.py 3 3 6
tests/test_data_upload.py 8 1 9
tests/test_presigned_url.py 8 0 8
tests/test_dbgap.py 4 1 5
tests/test_user_token.py 5 0 5
tests/test_user_login_activation.py 2 1 3
tests/test_fence_admin.py 2 0 2
tests/test_client_credentials.py 1 0 1
tests/test_oidc_client.py 2 0 2
tests/test_register_user.py 2 0 2
tests/test_google_data_access.py 1 0 1
tests/test_ras_authn.py 0 3 3
tests/test_ras_passport.py 0 3 3
TOTAL 91 15 106

Please find the detailed integration test report here

Please find the Github Action logs here

@coveralls

coveralls commented Aug 21, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37331405262

Coverage decreased (-0.01%) to 74.658%

Details

  • Coverage decreased (-0.01%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • 194 coverage regressions across 9 files.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

194 previously-covered lines in 9 files lost coverage.

File Lines Losing Coverage Coverage
sync/sync_users.py 108 81.85%
config.py 37 54.35%
blueprints/storage_creds/api.py 20 78.64%
models.py 10 86.34%
jwt/validate.py 8 85.51%
jwt/token.py 6 95.27%
authz/auth.py 2 94.87%
jwt/utils.py 2 75.0%
3a5712474808_make_user_active_field_non_nullable.py 1 94.74%

Coverage Stats

Coverage Status
Relevant Lines: 11984
Covered Lines: 8947
Line Coverage: 74.66%
Coverage Strength: 0.75 hits per line

💛 - Coveralls

…s to handle a user_id without needing to do full JWT validation (since Fence does that already)
@github-actions

Copy link
Copy Markdown

Integration Tests

filepath passed skipped SUBTOTAL
tests/test_oauth2.py 15 0 15
tests/test_drs_endpoint.py 22 3 25
tests/test_centralized_auth.py 16 0 16
tests/test_audit_service.py 3 3 6
tests/test_data_upload.py 8 1 9
tests/test_presigned_url.py 8 0 8
tests/test_dbgap.py 4 1 5
tests/test_user_token.py 5 0 5
tests/test_user_login_activation.py 2 1 3
tests/test_fence_admin.py 2 0 2
tests/test_oidc_client.py 2 0 2
tests/test_client_credentials.py 1 0 1
tests/test_register_user.py 2 0 2
tests/test_google_data_access.py 1 0 1
tests/test_ras_authn.py 0 3 3
tests/test_ras_passport.py 0 3 3
TOTAL 91 15 106

Please find the detailed integration test report here

Please find the Github Action logs here

@github-actions

Copy link
Copy Markdown

Integration Tests

filepath passed skipped SUBTOTAL
tests/test_oauth2.py 15 0 15
tests/test_drs_endpoint.py 22 3 25
tests/test_centralized_auth.py 16 0 16
tests/test_audit_service.py 3 3 6
tests/test_data_upload.py 8 1 9
tests/test_presigned_url.py 8 0 8
tests/test_dbgap.py 4 1 5
tests/test_user_token.py 5 0 5
tests/test_user_login_activation.py 2 1 3
tests/test_fence_admin.py 2 0 2
tests/test_register_user.py 2 0 2
tests/test_oidc_client.py 2 0 2
tests/test_google_data_access.py 1 0 1
tests/test_client_credentials.py 1 0 1
tests/test_ras_authn.py 0 3 3
tests/test_ras_passport.py 0 3 3
TOTAL 91 15 106

Please find the detailed integration test report here

Please find the Github Action logs here

@github-actions

Copy link
Copy Markdown

Integration Tests

Failed to Prepare CI environment

Please find the Github Action logs here

@github-actions

Copy link
Copy Markdown

Integration Tests

Failed to Prepare CI environment

Please find the Github Action logs here

Comment thread pyproject.toml
alembic = ">=1.7.7"
authlib = ">=1.6.10,<=1.6.12"
authutils = ">=8.0.0"
authutils = {git = "https://github.com/uc-cdis/authutils.git", rev = "feat/dpop"}

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.

Reminder to pin it to a release before merge.

Comment thread fence/config-default.yaml
#
# You can use openssl to generate this (but remember you MUST use the SAME secret across services)
# `openssl rand -base64 64`
DPOP_SHARED_SECRET: null

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.

Should there be any user-facing documentation added for the dPop setup steps, perhaps letting users know where they can add this secret as an environment variable in gen3-helm (fence/values.yaml#L346)? Or do you think this is outside the scope of fence?

Comment thread fence/jwt/utils.py Outdated
Comment on lines +21 to +23
if not header.lower().startswith("bearer") and not header.lower().startswith(
"dpop"
):

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.

Can combine both the header options into a single one.

Suggested change
if not header.lower().startswith("bearer") and not header.lower().startswith(
"dpop"
):
if not header.lower().startswith(("bearer", "dpop")):

Or better, to avoid cases like "dpopXyZ <token>", we could do

Suggested change
if not header.lower().startswith("bearer") and not header.lower().startswith(
"dpop"
):
if header.lower().split(" ", 1)[0] not in ("bearer", "dpop")

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.

and .partition(" ") instead of .split(" ", 1) 😄

or if not header.lower().startswith(("bearer ", "dpop ")):

so many options, so little code!

Comment thread fence/config.py Outdated
Comment thread fence/resources/storage/cdis_jwt.py Outdated


def create_user_access_token(keypair, api_key, expires_in, task_token_type):
def create_user_access_token(keypair, api_key, expires_in, task_token_type, cnf=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.

Not related to this PR, but tests/scripting/test_fence-create.py has methods named test_user_access_token_... that don't actually test this method. It might be worth renaming those tests to avoid future confusion.
Feel free to defer this if you think it’s out of scope or self-explanatory, since the file name already indicates that those tests are for fence-create.py.

Comment thread fence/jwt/dpop.py
now = int(time.time())
with flask.current_app.db.session as session:
# A proof older than this is rejected on `iat` alone, so its id stops mattering.
session.query(DPoPProofJTI).filter(DPoPProofJTI.exp < now).delete()

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.

We could add an index on the exp field here since we query the table based on expiry for every check.

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.

agreed, good call. working on adding it

Comment thread fence/blueprints/storage_creds/api.py Outdated
logger.error(f"Unknown error validating DPoP request: {exc}")
raise UserError("Error validating DPoP request")

# minting the token is what validates the api key and resolves the user it

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.

It seems a little out of the ordinary that we perform the entire DPoP proof validation and respond with a nonce on the first attempt before validating the API key here. This means a user with an invalid API key can still attempt to obtain a task token, and they would see the nonce error first rather than an expired/invalid API key error.

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.

Since create_user_access_token is only called once in the whole repo, can we refactor it so that api.py calls validate_jwt directly, before validate_dpop_proof, and then passes the resulting claims into create_user_access_token? That way an invalid API key is rejected before the DPoP proof is validated and its jti is written to the database, and we don't validate the key twice

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, agreed. will work on cleaning this up so the validation occurs once before nonce repsonse

Comment thread fence/jwt/dpop.py Outdated
with flask.current_app.db.session as session:
# A proof older than this is rejected on `iat` alone, so its id stops mattering.
session.query(DPoPProofJTI).filter(DPoPProofJTI.exp < now).delete()
session.add(DPoPProofJTI(jti=jti, exp=now + DPOP_PROOF_MAX_TTL))

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.

Should we also incorporate authutils.dpop.DPOP_PROOF_CLOCK_SKEW_LEEWAY while calculating exp?

Since iat = now() + skew_leeway is considered valid, so, shouldn't the jti be in the database until then?

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.

yep, you're right, this is a real gap. will adjust

sa.Column("exp", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("jti"),
)

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.

Consider adding an index over exp since it is used to query and delete during every jti_seen callback

"auth_request",
wraps=flask.current_app.arborist.auth_request,
) as auth_request:
assert can_user_get_task_token("FOO", 200, "test-user") is True

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
assert can_user_get_task_token("FOO", 200, "test-user") is True
assert can_user_get_task_token("FOO", 200, "test-user")

Unless the addition was intentional to be stricter. But I couldn't see any need for it.

Avantol13 and others added 3 commits October 2, 2026 14:44
Co-authored-by: Sai Shanmukha Narumanchi <nss10@outlook.com>

@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 (i did not review the tests)

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Integration Tests

filepath passed skipped SUBTOTAL
tests/test_oauth2.py 15 0 15
tests/test_centralized_auth.py 16 0 16
tests/test_audit_service.py 3 3 6
tests/test_data_upload.py 8 1 9
tests/test_drs_endpoint.py 25 0 25
tests/test_presigned_url.py 8 0 8
tests/test_dbgap.py 4 1 5
tests/test_user_token.py 5 0 5
tests/test_user_login_activation.py 2 1 3
tests/test_fence_admin.py 2 0 2
tests/test_register_user.py 2 0 2
tests/test_oidc_client.py 2 0 2
tests/test_google_data_access.py 1 0 1
tests/test_client_credentials.py 1 0 1
tests/test_ras_authn.py 0 3 3
tests/test_ras_passport.py 0 3 3
TOTAL 94 12 106

Please find the detailed integration test report here

Please find the Github Action logs here

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Integration Tests

filepath passed skipped SUBTOTAL
tests/test_oauth2.py 15 0 15
tests/test_centralized_auth.py 16 0 16
tests/test_audit_service.py 3 3 6
tests/test_data_upload.py 8 1 9
tests/test_drs_endpoint.py 25 0 25
tests/test_presigned_url.py 8 0 8
tests/test_dbgap.py 4 1 5
tests/test_user_token.py 5 0 5
tests/test_user_login_activation.py 2 1 3
tests/test_fence_admin.py 2 0 2
tests/test_register_user.py 2 0 2
tests/test_google_data_access.py 1 0 1
tests/test_client_credentials.py 1 0 1
tests/test_oidc_client.py 2 0 2
tests/test_ras_authn.py 0 3 3
tests/test_ras_passport.py 0 3 3
TOTAL 94 12 106

Please find the detailed integration test report here

Please find the Github Action logs here

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