diff --git a/README.md b/README.md index 564d23fdf..2e07d3644 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/dpop.ts b/lib/dpop.ts new file mode 100644 index 000000000..3c597d7f3 --- /dev/null +++ b/lib/dpop.ts @@ -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 { + // 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; + 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 | 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; + 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; + 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[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; + 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)`); + } + + // 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[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}"`, + ); + } +} + +/** + * 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 { + const cnf = claims?.cnf as Record | undefined; + return (cnf?.jkt as string) || ''; +} diff --git a/lib/index.ts b/lib/index.ts index bbf27cf85..1fe225328 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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; @@ -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 { + 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 @@ -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';