Skip to content

feat: add DPoP sender-constrained token support (RFC 9449)#732

Open
davidbemer wants to merge 3 commits into
mainfrom
dpop
Open

feat: add DPoP sender-constrained token support (RFC 9449)#732
davidbemer wants to merge 3 commits into
mainfrom
dpop

Conversation

@davidbemer
Copy link
Copy Markdown

Summary

  • Implements RFC 9449 DPoP proof validation in lib/dpop.ts with the full §7.1-7.2 algorithm (alg allowlist, JWK signature verification via jose, iat window, ath / cnf.jkt binding)
  • Adds sdk.validateDPoP(sessionToken, dpopProof, method, url) to the SDK object; exports validateDPoPProof and getDPoPThumbprint as standalone helpers
  • Documents the DPoP flow in README with an end-to-end code example; both Bearer and DPoP Authorization schemes are handled identically

Mirrors the Go SDK implementation: descope/go-sdk#737

🤖 Generated with Claude Code

- 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>
Copilot AI review requested due to automatic review settings May 20, 2026 15:37
@shuni-bot-dev
Copy link
Copy Markdown

shuni-bot-dev Bot commented May 20, 2026

🐕 Review complete — View session on Shuni Portal 🐾

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.ts with validateDPoPProof() + 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.

Comment thread lib/index.ts Outdated
import withLicense from './management/license';
import { AuthenticationInfo, IDPResponse, RefreshAuthenticationInfo, VerifyOptions } from './types';
import descopeErrors from './errors';
import { validateDPoPProof, getDPoPThumbprint } from './dpop';
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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'.

Comment thread lib/dpop.ts Outdated
Comment on lines +86 to +91
if (parts.length < 2) return;
let claims: Record<string, unknown>;
try {
claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
} catch {
return;
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread lib/dpop.ts
Comment on lines +204 to +210
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)`,
);
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread lib/dpop.ts
Comment on lines +77 to +82
export async function validateDPoPProof(
sessionToken: string,
dpopProof: string | undefined,
method: string,
requestUrl: string,
): Promise<void> {
Comment thread README.md Outdated
Comment on lines +506 to +508
req.method, // HTTP method in uppercase, e.g. "GET"
`https://${req.headers.host}${req.url}`, // absolute request URL
);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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).

Copy link
Copy Markdown

@shuni-bot-dev shuni-bot-dev Bot left a comment

Choose a reason for hiding this comment

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

🐕 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 jti replay protection / undocumented design choice
  • 1 🟢 LOW: unused import in lib/index.ts

See inline comments for details. Good bones, just needs a bath! Woof!

Comment thread lib/index.ts Outdated
import withLicense from './management/license';
import { AuthenticationInfo, IDPResponse, RefreshAuthenticationInfo, VerifyOptions } from './types';
import descopeErrors from './errors';
import { validateDPoPProof, getDPoPThumbprint } from './dpop';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 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.

Suggested change
import { validateDPoPProof, getDPoPThumbprint } from './dpop';
import { validateDPoPProof } from './dpop';

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread lib/dpop.ts
`DPoP proof JWK thumbprint "${thumbprint}" does not match session cnf.jkt "${storedJKT}"`,
);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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).

Comment thread lib/dpop.ts
throw new Error(
`DPoP proof iat is outside the acceptable window (diff=${diff.toFixed(2)}s)`,
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

davidbemer and others added 2 commits May 20, 2026 18:57
- 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>
@davidbemer
Copy link
Copy Markdown
Author

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.

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.

2 participants