Skip to content

feat(auth): DPoP sender-constrained tokens for machine identities - #75

Merged
PenguinzTech merged 2 commits into
v2.1.xfrom
feature/dpop-bound-tokens
Jul 27, 2026
Merged

feat(auth): DPoP sender-constrained tokens for machine identities#75
PenguinzTech merged 2 commits into
v2.1.xfrom
feature/dpop-bound-tokens

Conversation

@PenguinzTech

Copy link
Copy Markdown
Contributor

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.jkt confirmation 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

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>
@PenguinzTech PenguinzTech self-assigned this Jul 26, 2026
@PenguinzTech
PenguinzTech changed the base branch from feature/nhi-machine-tokens to v2.1.x July 27, 2026 01:07
- 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>
@PenguinzTech
PenguinzTech merged commit 78ecb3a into v2.1.x Jul 27, 2026
3 of 6 checks passed
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