Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,71 @@ for more information.

Note: if refresh token rotation is enabled in Descope - `refreshSession` / `validateAndRefreshSession` will return a new refresh token, and the old one will be invalidated.

#### DPoP Sender-Constrained Tokens (RFC 9449)

Descope supports DPoP (Demonstrated Proof of Possession) sender-constrained tokens. When a session
token contains a `cnf.jkt` claim, the client must prove possession of the corresponding private key
on every request by sending a `DPoP` header containing a signed proof JWT.

After validating the session with `validateSession`, call `validateDPoP` to verify the DPoP proof:

```typescript
import descopeClient from '@descope/node-sdk';

const sdk = descopeClient({ projectId: 'YOUR_PROJECT_ID' });

// In your request handler:
const sessionToken = req.headers.authorization?.replace(/^(Bearer|DPoP)\s+/i, '');
const dpopProof = req.headers['dpop'] as string | undefined;

// First validate the session token
const authInfo = await sdk.validateSession(sessionToken);

// Then validate the DPoP proof (no-op if token is not DPoP-bound)
await sdk.validateDPoP(
authInfo.jwt, // the raw session JWT string
dpopProof, // value of the DPoP header
req.method, // HTTP method in uppercase, e.g. "GET"
`https://${req.headers.host}${req.url}`, // absolute request URL
);
```

> **Important:** The URL passed to `validateDPoP` must exactly match the `htu` claim in the DPoP
> proof — same scheme, host, and path (query string and fragment are ignored). In environments
> behind a reverse proxy or load balancer, the scheme seen by Node.js may be `http` even when the
> client used `https`. Reconstruct the URL from request context to ensure they match:
>
> ```typescript
> // Express with trust proxy enabled (app.set('trust proxy', true)):
> const requestUrl = `${req.protocol}://${req.get('host')}${req.path}`;
>
> // Raw Node.js http/https using X-Forwarded-Proto:
> const scheme =
> req.headers['x-forwarded-proto'] ?? (req.socket as any).encrypted ? 'https' : 'http';
> const requestUrl = `${scheme}://${req.headers.host}${req.url}`;
> ```

If the token is DPoP-bound and the proof is missing or invalid, `validateDPoP` throws an error.
If the token has no `cnf.jkt` claim (i.e. it is a regular Bearer token), `validateDPoP` is a no-op.

The `DPoP` Authorization scheme is treated identically to `Bearer` — the session JWT that follows
the scheme prefix is the same Descope-issued token in both cases.

> **Note on jti replay protection (RFC 9449 §11.1):** `validateDPoP` does not implement jti replay
> protection because a stateless SDK has no shared storage to persist seen jti values across
> requests. If your application requires replay protection, maintain a server-side jti store (e.g.
> a Redis set keyed by jti with TTL equal to the iat window, approximately 60 s) and reject
> duplicate jti values before or after calling `validateDPoP`.

You can also import the standalone helpers directly:

```typescript
import { validateDPoPProof, getDPoPThumbprint } from '@descope/node-sdk';

// Extract cnf.jkt from already-parsed token claims
const jkt = getDPoPThumbprint(authInfo.token);
```

#### Session Validation Using Middleware

Alternatively, you can create a simple middleware function that internally uses the `validateSession` function.
Expand Down
248 changes: 248 additions & 0 deletions lib/dpop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { createHash } from 'crypto';
import { calculateJwkThumbprint, compactVerify, importJWK } from 'jose';

/** Allowed DPoP proof signing algorithms per RFC 9449 */
const ALLOWED_ALGS = new Set([
'RS256',
'RS384',
'RS512',
'ES256',
'ES384',
'ES512',
'PS256',
'PS384',
'PS512',
'EdDSA',
]);

/** Maximum DPoP proof JWT size in bytes */
const MAX_PROOF_LEN = 8192;

/** Backward clock skew tolerance in seconds */
const IAT_BACKWARD_WINDOW = 60;

/** Forward clock skew tolerance in seconds */
const IAT_FORWARD_WINDOW = 5;

/**
* Normalize a URL for DPoP `htu` comparison per RFC 9449 §4.2:
* - Lowercase scheme and host
* - Strip default ports (443 for https, 80 for http)
* - Strip query string and fragment
*/
function normalizeHtu(raw: string): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return '';
}
const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
const host = parsed.hostname.toLowerCase();
const { port } = parsed;
let defaultPort: string;
if (scheme === 'https') {
defaultPort = '443';
} else if (scheme === 'http') {
defaultPort = '80';
} else {
defaultPort = '';
}

const portStr = port && port !== defaultPort ? `:${port}` : '';
return `${scheme}://${host}${portStr}${parsed.pathname}`;
}

/**
* Base64url-encode a buffer without padding characters.
*/
function base64urlNoPad(buf: Buffer): string {
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

/**
* Validate a DPoP proof JWT against the provided session token, HTTP method, and request URL.
*
* @param sessionToken - The validated session JWT string (used for `ath` claim verification)
* @param dpopProof - The value of the `DPoP` HTTP header from the incoming request
* @param method - HTTP method of the request, uppercase (e.g. "GET", "POST")
* @param requestUrl - Absolute URL of the request (e.g. "https://api.example.com/resource")
*
* @throws Error if DPoP validation fails for any reason
* @returns void — resolves successfully if the proof is valid, or is a no-op when the session
* token has no `cnf.jkt` claim (i.e. is not DPoP-bound)
*
* @note jti replay protection (RFC 9449 §11.1) is intentionally NOT implemented here.
* A stateless SDK has no shared storage in which to persist seen jti values across requests.
* Integrators who require replay protection should maintain a server-side jti store (e.g.
* a Redis set keyed by jti with TTL equal to the iat window) and reject duplicate jti values
* before or after calling this function.
*/
export async function validateDPoPProof(
sessionToken: string,
dpopProof: string | undefined,
method: string,
requestUrl: string,
): Promise<void> {
Comment on lines +81 to +86
// Decode the session JWT claims to check for cnf.jkt (no signature verification needed here —
// the caller has already validated the session token via validateSession).
// Fail closed: if the session token is malformed we cannot determine whether it is DPoP-bound,
// so we throw rather than silently treating it as a plain Bearer token.
const parts = sessionToken.split('.');
if (parts.length < 2) {
throw new Error('Session token is invalid: not a valid JWT');
}
let claims: Record<string, unknown>;
try {
claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
} catch {
throw new Error('Session token is invalid: could not decode JWT payload');
}

const cnf = claims?.cnf as Record<string, unknown> | undefined;
const storedJKT = (cnf?.jkt as string) || '';
if (!storedJKT) {
// Token is not DPoP-bound — nothing to validate
return;
}

// --- From here on, the token IS DPoP-bound. Proof is required. ---

const proof = (dpopProof ?? '').trim();

if (proof.length > MAX_PROOF_LEN) {
throw new Error('DPoP proof exceeds maximum length');
}

if (!proof) {
throw new Error('DPoP proof required: access token is DPoP-bound (cnf.jkt present)');
}

// Step 4-5: Parse protected header (first part of JWS compact serialization)
const proofParts = proof.split('.');
if (proofParts.length !== 3) {
throw new Error('DPoP proof must be a compact JWS with exactly 3 parts');
}

let header: Record<string, unknown>;
try {
header = JSON.parse(Buffer.from(proofParts[0], 'base64url').toString('utf8'));
} catch {
throw new Error('DPoP proof header is not valid base64url-encoded JSON');
}

// Step 6: typ must be "dpop+jwt"
if (header.typ !== 'dpop+jwt') {
throw new Error(`DPoP proof header typ must be "dpop+jwt", got "${header.typ}"`);
}

// Step 7: alg must be in ALLOWED_ALGS
const alg = header.alg as string;
if (!alg || !ALLOWED_ALGS.has(alg)) {
throw new Error(`DPoP proof algorithm "${alg}" is not allowed`);
}

// Step 8: extract embedded JWK
const jwk = header.jwk as Record<string, unknown>;
if (!jwk || typeof jwk !== 'object') {
throw new Error('DPoP proof header must contain a jwk claim');
}

// Step 9: no symmetric keys
if (jwk.kty === 'oct') {
throw new Error('DPoP proof JWK must not use symmetric key type (oct)');
}

// Step 10: no private key components
if ('d' in jwk) {
throw new Error('DPoP proof JWK must not contain private key components');
}

// Step 11: verify JWS signature using the embedded JWK
let payloadBytes: Uint8Array;
try {
const cryptoKey = await importJWK(jwk as Parameters<typeof importJWK>[0], alg);
const result = await compactVerify(proof, cryptoKey);
payloadBytes = result.payload;
} catch (err) {
throw new Error(`DPoP proof signature verification failed: ${err}`);
}

// Step 12: parse JWT payload
let payload: Record<string, unknown>;
try {
payload = JSON.parse(Buffer.from(payloadBytes).toString('utf8'));
} catch {
throw new Error('DPoP proof payload is not valid JSON');
}

// Steps 13-15: required string claims
if (!payload.jti || typeof payload.jti !== 'string') {
throw new Error('DPoP proof payload must contain a non-empty string jti claim');
}
if (!payload.htm || typeof payload.htm !== 'string') {
throw new Error('DPoP proof payload must contain a non-empty string htm claim');
}
if (!payload.htu || typeof payload.htu !== 'string') {
throw new Error('DPoP proof payload must contain a non-empty string htu claim');
}

// Step 16: htm must match the HTTP method
if (payload.htm !== method) {
throw new Error(`DPoP proof htm "${payload.htm}" does not match request method "${method}"`);
}

// Step 17: htu must match the request URL (scheme+host+path, ignore query/fragment)
const normalizedHtu = normalizeHtu(payload.htu as string);
const normalizedUrl = normalizeHtu(requestUrl);
if (!normalizedHtu || !normalizedUrl || normalizedHtu !== normalizedUrl) {
throw new Error(`DPoP proof htu "${payload.htu}" does not match request URL "${requestUrl}"`);
}

// Steps 18-21: iat window check (no exp in DPoP proofs)
const { iat } = payload;
if (typeof iat !== 'number') {
throw new Error('DPoP proof payload must contain a numeric iat claim');
}
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)`);
}
Comment on lines +207 to +211
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.

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.


// Steps 22-24: ath claim — sha256(sessionToken) as base64url without padding
const { ath } = payload;
if (!ath || typeof ath !== 'string') {
throw new Error('DPoP proof payload must contain a non-empty string ath claim');
}
const expectedAth = base64urlNoPad(createHash('sha256').update(sessionToken).digest());
if (ath !== expectedAth) {
throw new Error('DPoP proof ath claim does not match the session token hash');
}

// Steps 25-26: JWK thumbprint must match cnf.jkt in the session token
let thumbprint: string;
try {
thumbprint = await calculateJwkThumbprint(
jwk as Parameters<typeof calculateJwkThumbprint>[0],
'sha256',
);
} catch (err) {
throw new Error(`Failed to compute DPoP JWK thumbprint: ${err}`);
}

if (thumbprint !== storedJKT) {
throw new Error(
`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).


/**
* Extract the DPoP JWK thumbprint from token claims.
* Returns an empty string if the token is not DPoP-bound.
*/
export function getDPoPThumbprint(claims: Record<string, unknown>): string {
const cnf = claims?.cnf as Record<string, unknown> | undefined;
return (cnf?.jkt as string) || '';
}
23 changes: 23 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import withManagement from './management';
import withLicense from './management/license';
import { AuthenticationInfo, IDPResponse, RefreshAuthenticationInfo, VerifyOptions } from './types';
import descopeErrors from './errors';
import { validateDPoPProof } from './dpop';

declare const BUILD_VERSION: string;

Expand Down Expand Up @@ -361,6 +362,27 @@ const nodeSdk = ({
}
},

/**
* Validate a DPoP proof JWT for a DPoP-bound session token (RFC 9449).
*
* Call this after `validateSession` when your server receives requests that may carry
* a `DPoP` header. If the session token does not have a `cnf.jkt` claim this is a no-op.
*
* @param sessionToken - The validated session JWT string returned by `validateSession`
* @param dpopProof - The value of the `DPoP` header from the incoming request
* @param method - HTTP method of the request in uppercase, e.g. `"GET"` or `"POST"`
* @param requestUrl - Absolute URL of the request, e.g. `"https://api.example.com/resource"`
* @throws Error if the DPoP proof is missing, invalid, or does not match the session token
*/
async validateDPoP(
sessionToken: string,
dpopProof: string | undefined,
method: string,
requestUrl: string,
): Promise<void> {
return validateDPoPProof(sessionToken, dpopProof, method, requestUrl);
},

/**
* Make sure that all given permissions exist on the parsed JWT top level claims
* @param authInfo JWT parsed info
Expand Down Expand Up @@ -530,3 +552,4 @@ export type { AuthenticationInfo, IDPResponse, RefreshAuthenticationInfo };
export type { VerifyOptions } from './types';
export * from './management/types';
export type { PatchUserOptions } from './management/user';
export { validateDPoPProof, getDPoPThumbprint } from './dpop';
Loading