feat: add DPoP sender-constrained token support (RFC 9449)#732
feat: add DPoP sender-constrained token support (RFC 9449)#732davidbemer wants to merge 3 commits into
Conversation
- Add lib/dpop.ts with validateDPoPProof and getDPoPThumbprint implementing the full RFC 9449 §7.1-7.2 validation algorithm - Expose sdk.validateDPoP() on the SDK object and re-export standalone helpers from lib/index.ts - Document DPoP usage in README with a code example; note that DPoP and Bearer auth schemes are interchangeable for Descope session tokens Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
🐕 Review complete — View session on Shuni Portal 🐾 |
There was a problem hiding this comment.
Pull request overview
Adds RFC 9449 DPoP sender-constrained token validation to the Node SDK so servers can verify a request’s DPoP proof JWT against a Descope session token’s cnf.jkt binding.
Changes:
- Introduces
lib/dpop.tswithvalidateDPoPProof()+getDPoPThumbprint()helpers (signature verification,htm/htu,iat,ath, thumbprint binding). - Adds
sdk.validateDPoP(sessionToken, dpopProof, method, requestUrl)and re-exports the standalone helpers from the package entrypoint. - Documents the DPoP flow and a usage example in the README.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| README.md | Adds end-to-end documentation and sample code for validating DPoP proofs. |
| lib/index.ts | Wires a new validateDPoP method into the SDK and re-exports helper functions. |
| lib/dpop.ts | Implements DPoP proof validation logic and token cnf.jkt thumbprint extraction. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import withLicense from './management/license'; | ||
| import { AuthenticationInfo, IDPResponse, RefreshAuthenticationInfo, VerifyOptions } from './types'; | ||
| import descopeErrors from './errors'; | ||
| import { validateDPoPProof, getDPoPThumbprint } from './dpop'; |
There was a problem hiding this comment.
Fixed — removed getDPoPThumbprint from the import statement on line 29. It was only imported but never referenced in index.ts; the re-export at the bottom via export { ... } from './dpop' is a separate construct that does not require an import. The import now reads import { validateDPoPProof } from './dpop'.
| if (parts.length < 2) return; | ||
| let claims: Record<string, unknown>; | ||
| try { | ||
| claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); | ||
| } catch { | ||
| return; |
There was a problem hiding this comment.
Fixed — added fail-closed behavior. Instead of silently returning when parts.length < 2 or the JSON decode fails, we now throw an error ('Session token is invalid: not a valid JWT' / 'Session token is invalid: could not decode JWT payload'). This ensures a malformed session token is never silently treated as a non-DPoP-bound Bearer token, which could bypass key binding validation entirely.
| const now = Date.now() / 1000; | ||
| const diff = now - iat; | ||
| if (diff <= -IAT_FORWARD_WINDOW || diff >= IAT_BACKWARD_WINDOW) { | ||
| throw new Error( | ||
| `DPoP proof iat is outside the acceptable window (diff=${diff.toFixed(2)}s)`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Intentional — this matches go-sdk's behavior (descope/go-sdk#737 uses diff >= dpopIATWindow), which rejects at the boundary (exactly ±window). The window is exclusive on both ends by design: a proof at exactly +60 s or -5 s is stale/future and should be rejected. No change needed.
| export async function validateDPoPProof( | ||
| sessionToken: string, | ||
| dpopProof: string | undefined, | ||
| method: string, | ||
| requestUrl: string, | ||
| ): Promise<void> { |
| req.method, // HTTP method in uppercase, e.g. "GET" | ||
| `https://${req.headers.host}${req.url}`, // absolute request URL | ||
| ); |
There was a problem hiding this comment.
Fixed — added a note in the README clarifying that the URL passed to validateDPoP must exactly match the htu claim (same scheme, host, and path). The note includes examples for reconstructing the URL correctly in reverse-proxy and load-balancer environments (e.g. using X-Forwarded-Proto or Express's req.protocol).
There was a problem hiding this comment.
🐕 Shuni's Review
Adds RFC 9449 DPoP proof validation (lib/dpop.ts) and exposes it on the SDK as validateDPoP, plus standalone helpers. Core algorithm (alg allowlist, JWS verification via jose, htm/htu/iat/ath/cnf.jkt checks) looks correct and mirrors RFC §7.1-7.2 faithfully.
Sniffed out 3 issues:
- 2 🟡 MEDIUM: no tests for ~240 LoC of security-critical crypto code; no
jtireplay protection / undocumented design choice - 1 🟢 LOW: unused import in
lib/index.ts
See inline comments for details. Good bones, just needs a bath! Woof!
| import withLicense from './management/license'; | ||
| import { AuthenticationInfo, IDPResponse, RefreshAuthenticationInfo, VerifyOptions } from './types'; | ||
| import descopeErrors from './errors'; | ||
| import { validateDPoPProof, getDPoPThumbprint } from './dpop'; |
There was a problem hiding this comment.
🟢 LOW: getDPoPThumbprint is imported here but never referenced in this file — it's re-exported at line 555 via export { ... } from './dpop', which is a separate construct that doesn't depend on this import. airbnb-base's no-unused-vars / @typescript-eslint/no-unused-vars will flag this.
| import { validateDPoPProof, getDPoPThumbprint } from './dpop'; | |
| import { validateDPoPProof } from './dpop'; |
There was a problem hiding this comment.
Fixed — removed getDPoPThumbprint from the import on line 29. The re-export at the bottom via export { ... } from './dpop' is independent and doesn't require this import.
| `DPoP proof JWK thumbprint "${thumbprint}" does not match session cnf.jkt "${storedJKT}"`, | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 MEDIUM: No test file (lib/dpop.test.ts) exists for this module. This is ~240 lines of security-critical validation code (JWS verification, JWK thumbprint binding, ath/htm/htu/iat checks) covering many failure paths — and a regression here either silently accepts forged proofs or breaks all DPoP-bound sessions.
At minimum, please add tests for: valid proof acceptance, each rejection path (bad typ, bad alg, symmetric key, private key, signature mismatch, missing/wrong jti/htm/htu/iat/ath, iat outside both windows, JWK thumbprint mismatch), the non-DPoP-bound no-op path, and the htu normalization edge cases (default ports, query/fragment, casing).
| throw new Error( | ||
| `DPoP proof iat is outside the acceptable window (diff=${diff.toFixed(2)}s)`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🟡 MEDIUM: No jti replay protection. RFC 9449 §11.1 recommends servers track previously-seen jti values (scoped per-client) to prevent DPoP proof replay within the iat window. As implemented, an attacker who captures a valid DPoP proof can replay it for up to ~60s against the same htm+htu+session token.
A stateless SDK can't track jti itself (needs a shared store), but this responsibility should be called out explicitly in the README and the validateDPoP JSDoc so consumers know they must layer replay tracking on top. Otherwise developers will reasonably assume validateDPoP is sufficient for full RFC 9449 compliance.
There was a problem hiding this comment.
Deferred — jti replay protection requires server-side storage which a stateless SDK cannot provide. This matches the go-sdk reference implementation (descope/go-sdk#737). Added a @note to the validateDPoPProof JSDoc and a callout block in the README documenting the gap and directing integrators to implement a server-side jti store (e.g. Redis with TTL ≈ iat window) if replay protection is required.
- Remove unused getDPoPThumbprint import from lib/index.ts (it is already re-exported via export { ... } from './dpop')
- Fail closed on malformed session token: throw instead of silently returning so an undecodable JWT is never treated as non-DPoP-bound
- Document jti replay protection gap in dpop.ts docstring and README (RFC 9449 §11.1 — stateless SDK cannot implement shared storage)
- Add README note clarifying that the URL passed to validateDPoP must exactly match the htu claim, with examples for reverse-proxy deployments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Reorder getDPoPThumbprint export to satisfy import/exports-last rule - Inline cnf.jkt extraction to avoid no-use-before-define - Replace nested ternary with if/else for no-nested-ternary rule - Apply prettier formatting to dpop.ts and README.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Fixed: applied Prettier auto-formatting and resolved ESLint errors in dpop.ts and README.md. The CI was failing due to style inconsistencies (nested ternary, export ordering, missing destructuring). All pre-commit hooks now pass locally. |
Summary
lib/dpop.tswith the full §7.1-7.2 algorithm (alg allowlist, JWK signature verification viajose,iatwindow,ath/cnf.jktbinding)sdk.validateDPoP(sessionToken, dpopProof, method, url)to the SDK object; exportsvalidateDPoPProofandgetDPoPThumbprintas standalone helpersBearerandDPoPAuthorization schemes are handled identicallyMirrors the Go SDK implementation: descope/go-sdk#737
🤖 Generated with Claude Code