feat(auth): DPoP sender-constrained tokens for machine identities - #75
Merged
Conversation
Implements RFC 9449 Demonstrating Proof-of-Possession (DPoP) for machine identities. DPoP binds access tokens to a public key the client must prove possession of, preventing token exfiltration attacks (stolen tokens are useless without the private key). Features: - DPoP proof validation with ES256/RS256 signatures - JWK embedded in proof header with RFC 7638 thumbprint - Binding via RFC 9449 cnf claim (confirmation) - Replay defense via jti (JWT ID) tracking with DB persistence - Resource-side enforcement decorator for token validation - Full backward compatibility with bearer tokens (no regression) Replay Defense Tests (verified): - test_same_jti_replayed_rejected: confirms jti uniqueness enforcement - test_different_jti_same_key_accepted: confirms scope isolation - test_replay_cleanup_removes_stale_rows: confirms expiry cleanup works Test coverage: 29 comprehensive tests covering: - Proof validation (header, algorithm, claims, signature) - JWK thumbprint correctness - Replay defense (first acceptance, second rejection, cleanup) - Token issuance with cnf binding - Bearer token backward compatibility - Stolen token scenarios (token without proof → 401) Migration 011: dpop_replay table for jti tracking Security fixes: - Removed skip_jti_check parameter (was security bypass in tests) - All tests now use real DB fixture for replay defense verification Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- app/blueprints/auth.py: both grant handlers (client_credentials and token-exchange) needed to combine THIS branch's dpop_jkt binding with #71's already-merged allowed_domains DB-fetch, both passed to create_machine_access_token. - app/services/auth_service.py: create_machine_access_token's signature/docstring/body combined to accept and apply both dpop_jkt (RFC 9449 cnf claim) and allowed_domains (dns_domains claim). - app/schema.py + tests/test_schema.py: dpop_replay table (this branch) combined with audit_event (#61, already merged) -- this branch predates that merge so git saw it as new. - alembic: two real chain issues, both from this branch predating later merges: 1. 011_dpop_replays.py had NO revision/down_revision identifiers at all (a genuine bug -- alembic could never have discovered this migration). Added revision="011_dpop_replays", down_revision="010_dns_domain_allowlists" (010 is #71's migration, merged after this branch was authored; the file's stale docstring said "Depends on: 009"). 2. 008_add_mfa_fields (re-chained onto 009 in an earlier standalone v2.1.x fix, before #71 existed) and 010_dns_domain_allowlists both forked from 009, creating two heads. Re-chained 008_add_mfa_fields -> 011_dpop_replays so the graph is linear again with 008_add_mfa_fields as the single head. Full manager suite: 293/293 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DPoP (RFC 9449) sender-constrained tokens for non-human identities on top of #63. A stolen machine/agent access token is useless without the private key it was bound to: proof-of-possession JWTs (ES256/RS256 only, JWK thumbprint per RFC 7638) are validated on issuance (client_credentials + token-exchange) and bound via a
cnf.jktconfirmation claim; resource-side enforcement requires a matching proof (right key, right htm/htu, fresh iat) on every request for a bound token — presenting the bearer token alone, or a proof signed by a different key, is rejected. Server-side jti replay defense with expiry cleanup. Tokens without a DPoP header behave exactly as bearer today — zero regression.Tests: 199 green (26 DPoP-specific, including the actual theft scenario — mismatched-key proof rejected — and real replay-rejection tests, no bypass flags in the validation path).
Stack note: Part 3 of the NHI stack — depends on #63 (
feature/nhi-machine-tokens); auto-retargets as the stack merges.🤖 Generated with Claude Code