-
Notifications
You must be signed in to change notification settings - Fork 8
feat: add DPoP sender-constrained token support (RFC 9449) #732
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,240 @@ | ||
| 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; | ||
|
|
||
| /** | ||
| * 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) || ''; | ||
| } | ||
|
|
||
| /** | ||
| * 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.port; | ||
| const defaultPort = scheme === 'https' ? '443' : scheme === 'http' ? '80' : ''; | ||
|
|
||
| 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) | ||
| */ | ||
| 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). | ||
| const parts = sessionToken.split('.'); | ||
| if (parts.length < 2) return; | ||
| let claims: Record<string, unknown>; | ||
| try { | ||
| claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); | ||
| } catch { | ||
| return; | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — added fail-closed behavior. Instead of silently returning when |
||
| } | ||
|
|
||
| const storedJKT = getDPoPThumbprint(claims); | ||
| 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.iat; | ||
| 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intentional — this matches go-sdk's behavior (descope/go-sdk#737 uses There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM: No A stateless SDK can't track
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| // Steps 22-24: ath claim — sha256(sessionToken) as base64url without padding | ||
| const ath = payload.ath; | ||
| 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}"`, | ||
| ); | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM: No test file ( At minimum, please add tests for: valid proof acceptance, each rejection path (bad |
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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, getDPoPThumbprint } from './dpop'; | ||||||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — removed There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 LOW:
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — removed |
||||||
|
|
||||||
| 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<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 | ||||||
|
|
@@ -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'; | ||||||
There was a problem hiding this comment.
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
validateDPoPmust exactly match thehtuclaim (same scheme, host, and path). The note includes examples for reconstructing the URL correctly in reverse-proxy and load-balancer environments (e.g. usingX-Forwarded-Protoor Express'sreq.protocol).