From 8bd86aeb5c4ce903819b175505efe7a47667c3ff Mon Sep 17 00:00:00 2001 From: sundayScoop Date: Wed, 28 Jan 2026 19:37:49 +1000 Subject: [PATCH] Add DPoP support to keycloak-js Closes #8 Signed-off-by: sundayScoop --- .../securing-apps/javascript-adapter.adoc | 82 +++ lib/keycloak-dpop.d.ts | 88 +++ lib/keycloak-dpop.js | 557 ++++++++++++++ lib/keycloak.d.ts | 47 +- lib/keycloak.js | 412 +++++++++-- test/tests/dpop.spec.ts | 697 ++++++++++++++++++ 6 files changed, 1815 insertions(+), 68 deletions(-) create mode 100644 lib/keycloak-dpop.d.ts create mode 100644 lib/keycloak-dpop.js create mode 100644 test/tests/dpop.spec.ts diff --git a/docs/guides/securing-apps/javascript-adapter.adoc b/docs/guides/securing-apps/javascript-adapter.adoc index 7b3ca70..1f0ba6b 100644 --- a/docs/guides/securing-apps/javascript-adapter.adoc +++ b/docs/guides/securing-apps/javascript-adapter.adoc @@ -285,6 +285,88 @@ await keycloak.init({ Naturally you can also do this without TypeScript by omitting the type information, but ensuring implementing the interface properly will then be left entirely up to you. +[[_dpop]] +== Securing Clients with DPoP +DPoP is a mechanism that prevents token theft and replay attacks by generating sender-constrained access and refresh tokens. It allows a client to cryptographically prove possession of the private key bound to a token, provided the resource server supports the DPoP standard. + +[NOTE] +==== +For more information on the DPoP standard, see the https://datatracker.ietf.org/doc/html/rfc9449[RFC 9449]. +==== + +=== Key Storage and Persistence +DPoP key pairs are persisted in IndexedDB, scoped to the specific issuer and client ID combination. This means keys survive page reloads and browser restarts, ensuring token continuity across sessions. If IndexedDB is unavailable, the adapter falls back to in-memory storage (keys will not survive page reloads). On logout, stored keys are automatically cleared. + +=== Initializing with DPoP +Enabling DPoP requires passing a `useDPoP` configuration object to the `init` method. The quickest and most straightforward way to enable DPoP is: +[source,javascript,subs="attributes+"] +---- +await keycloak.init({ + useDPoP: { + mode: 'auto' + } +}); +---- + +WARNING: DPoP requires the https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API[Web Crypto API], which is only available in https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Secure_Contexts[secure contexts] (HTTPS). + +When `mode` is set to `auto`, the adapter only uses DPoP if the authorization server advertises support for it in its OpenID configuration. If the server returns a non-DPoP token type, the adapter will log a warning and fall back to Bearer tokens for the remainder of the session. When `mode` is set to `strict`, the adapter will throw an error if the authorization server does not support DPoP or returns a non-DPoP token type. + +The adapter automatically generates DPoP proofs for token refresh requests, so no additional configuration is needed to maintain DPoP-bound tokens throughout the session lifecycle. + +Furthermore, you can specify which signing algorithm to use for DPoP keys. The supported algorithms are: + +* ES256 +* ES384 +* ES512 +* EdDSA + +[NOTE] +==== +The default algorithm is ES256. Not all browsers support EdDSA keys — if a user's browser does not support it, the adapter will automatically fall back to ES256. +==== + +Setting a preferred algorithm can be done by setting `alg` to one of the supported algorithms: +[source,javascript,subs="attributes+"] +---- +await keycloak.init({ + useDPoP: { + mode: 'auto', + alg: 'EdDSA' + } +}); +---- + +=== Using `secureFetch` +The `secureFetch` function is simply a wrapper around the global `fetch` function that also handles the nonce management required by DPoP secured resource servers. + +Migrating from making a HTTP request using `fetch` to `secureFetch` is as simple as: +[source,javascript,subs="attributes+"] +---- +// Before migration with basic fetch +const response = await fetch('/api/users', { + headers: { + accept: 'application/json', + authorization: `Bearer ${r"${keycloak.token}"}` + } +}); + +// After migration with secureFetch +const response = await keycloak.secureFetch('/api/users', { + headers: { + accept: 'application/json', + authorization: `Bearer ${r"${keycloak.token}"}` + } +}); +---- + +IMPORTANT: `secureFetch` requires you to set a `Bearer` Authorization header with the Keycloak-managed token. This is how it identifies which requests should be DPoP-protected — if the header is missing or does not match the adapter's token, the request is passed through to `fetch` unchanged with no DPoP proof attached. + +When a matching `Bearer` Authorization header is found, `secureFetch` automatically replaces it with the `DPoP` scheme and attaches the required proof. + +`secureFetch` handles nonce management internally. If a resource server responds with a `use_dpop_nonce` error, `secureFetch` will automatically retry the request once with the server-provided nonce. If the retry also fails, the error response is returned as-is. In all cases, `secureFetch` returns a standard `Response` object just like `fetch` would. + + [[_modern_browsers]] == Modern Browsers with Tracking Protection In the latest versions of some browsers, various cookies policies are applied to prevent tracking of the users by third parties, such as SameSite in Chrome or completely blocked third-party cookies. Those policies are likely to become more restrictive and adopted by other browsers over time. Eventually cookies in third-party contexts may become completely unsupported and blocked by the browsers. As a result, the affected adapter features might ultimately be deprecated. diff --git a/lib/keycloak-dpop.d.ts b/lib/keycloak-dpop.d.ts new file mode 100644 index 0000000..17d2e09 --- /dev/null +++ b/lib/keycloak-dpop.d.ts @@ -0,0 +1,88 @@ +/** Taken from {@link https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto#supported_algorithms here} - excluding RSA. */ +export enum BrowserSignatureAlgs { + ES256 = 'ES256', + ES384 = 'ES384', + ES512 = 'ES512', + EdDSA = 'EdDSA' +} + +/** + * State stored in IndexedDB for DPoP. + */ +export interface DPoPState { + /** The browser-native crypto key pair */ + keys: CryptoKeyPair + /** Authorization server's DPoP nonce */ + nonce?: string +} + +/** + * Options for creating a DPoPSignatureProvider. + */ +export interface DPoPSignatureProviderOptions { + /** The authorization server issuer URL */ + issuerUrl: URL + /** The OIDC client identifier */ + clientId: string + /** Algorithms supported by the server (from dpop_signing_alg_values_supported) */ + serverSupportedAlgorithms: string[] + /** Preferred signing algorithm (defaults to ES256) */ + requestedAlgorithm?: BrowserSignatureAlgs + /** If true, throws when IndexedDB unavailable instead of using memory fallback */ + strictStorage?: boolean + /** Returns the estimated time skew (browser - server) in seconds */ + getTimeSkew?: () => number +} + +/** + * Provides DPoP proof generation for OAuth 2.0 token binding. + * @see https://datatracker.ietf.org/doc/html/rfc9449 + */ +export class DPoPSignatureProvider { + constructor (options: DPoPSignatureProviderOptions) + + /** Initialize the provider. Must be called before generating proofs. */ + init (): Promise + + /** + * Clear all stored DPoP state (keys and nonce) for this client. Called on logout. + * Only affects the keys for this specific issuer+clientId combination. + */ + flush (): Promise + + /** + * Get the stored authorization server nonce. + * @returns The stored nonce, or undefined if none exists + */ + getAuthServerNonce (): Promise + + /** + * Update the authorization server nonce. Called after receiving DPoP-Nonce header. + * @param nonce - The nonce from the DPoP-Nonce response header + */ + updateAuthServerNonce (nonce: string): Promise + + /** + * Get the stored resource server nonce for a given origin. + * @param origin - The resource server origin (e.g., "https://api.example.com") + * @returns The stored nonce, or undefined if none exists + */ + getResourceServerNonce (origin: string): string | undefined + + /** + * Update the resource server nonce for a given origin. Called after receiving DPoP-Nonce header. + * @param origin - The resource server origin + * @param nonce - The nonce from the DPoP-Nonce response header + */ + updateResourceServerNonce (origin: string, nonce: string): void + + /** + * Generate a DPoP proof JWT for a request. + * @param url - The HTTP target URI + * @param httpMethod - The HTTP method (GET, POST, etc.) + * @param accessToken - Access token to bind (for resource server requests) + * @param nonce - Server-provided nonce + * @returns The DPoP proof JWT + */ + generateDPoPProof (url: string, httpMethod: string, accessToken?: string, nonce?: string): Promise +} diff --git a/lib/keycloak-dpop.js b/lib/keycloak-dpop.js new file mode 100644 index 0000000..8512f9e --- /dev/null +++ b/lib/keycloak-dpop.js @@ -0,0 +1,557 @@ +// @ts-check +/** + * @import { DPoPState, DPoPSignatureProviderOptions } from "./keycloak-dpop.ts" + */ +/* global indexedDB */ + +/** @enum {string} */ +export const BrowserSignatureAlgs = { + ES256: 'ES256', + ES384: 'ES384', + ES512: 'ES512', + EdDSA: 'EdDSA' +} + +const DB_VERSION = 1 +const STORE_NAME = 'main' +const STATE_KEY = 'dpopState' + +/** @type {Record} */ +const KEY_GEN_PARAMS = { + ES256: { name: 'ECDSA', namedCurve: 'P-256' }, + ES384: { name: 'ECDSA', namedCurve: 'P-384' }, + ES512: { name: 'ECDSA', namedCurve: 'P-521' }, + EdDSA: { name: 'Ed25519' } +} + +/** @type {Record} */ +const SIGN_PARAMS = { + ES256: { name: 'ECDSA', hash: 'SHA-256' }, + ES384: { name: 'ECDSA', hash: 'SHA-384' }, + ES512: { name: 'ECDSA', hash: 'SHA-512' }, + EdDSA: { name: 'Ed25519' } +} + +/** Fallback order when EdDSA is unsupported */ +const ECDSA_FALLBACK_ORDER = [ + BrowserSignatureAlgs.ES256, + BrowserSignatureAlgs.ES384, + BrowserSignatureAlgs.ES512 +] + +/** + * Creates a safe database name from issuer and clientId. + * Uses SHA-256 hash of both issuer and clientId to avoid special characters + * and prevent injection attacks via malicious clientId values. + * @param {string} issuer - The authorization server issuer URL + * @param {string} clientId - The OIDC client identifier + * @returns {Promise} A sanitized database name + */ +async function createDbName (issuer, clientId) { + const encoder = new TextEncoder() + + // Hash issuer + const issuerData = encoder.encode(issuer) + const issuerHashBuffer = await crypto.subtle.digest('SHA-256', issuerData) + const issuerHashArray = new Uint8Array(issuerHashBuffer) + const issuerHash = Array.from(issuerHashArray.slice(0, 8)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + + // Hash clientId to prevent special character issues and injection + const clientData = encoder.encode(clientId) + const clientHashBuffer = await crypto.subtle.digest('SHA-256', clientData) + const clientHashArray = new Uint8Array(clientHashBuffer) + const clientHash = Array.from(clientHashArray.slice(0, 8)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + + return `dpop:${issuerHash}:${clientHash}` +} + +/** + * Manages persistent storage of DPoP key pairs and nonces in IndexedDB. + * Falls back to in-memory storage when IndexedDB is unavailable. + * + * Database is named using a hash of the issuer URL + clientId to ensure + * isolation between different authorization servers. + * + * @see https://datatracker.ietf.org/doc/html/rfc9449 + */ +class DPoPStoreManager { + /** @type {string} */ + #dbName = '' + + /** @type {IDBDatabase | null} */ + #db = null + + /** @type {boolean} */ + #useMemoryFallback = false + + /** @type {DPoPState | undefined} */ + #memoryStore = undefined + + /** @type {string} */ + #issuer + + /** @type {string} */ + #clientId + + /** @type {boolean} */ + #strictStorage + + /** + * @param {string} issuer - The authorization server issuer URL + * @param {string} clientId - The OIDC client identifier + * @param {boolean} [strictStorage=false] - If true, throws error when IndexedDB is unavailable instead of falling back to memory + */ + constructor (issuer, clientId, strictStorage = false) { + this.#issuer = issuer + this.#clientId = clientId + this.#strictStorage = strictStorage + } + + /** + * Initialize the store manager. Must be called before other operations. + * @returns {Promise} + */ + async init () { + if (this.#db || this.#useMemoryFallback) { + return this // Already initialized + } + + try { + this.#dbName = await createDbName(this.#issuer, this.#clientId) + this.#db = await this.#openDatabase() + } catch (error) { + if (this.#strictStorage) { + throw new Error('DPoP requires IndexedDB for secure key storage, but it is unavailable.', { cause: error }) + } + console.warn('[KEYCLOAK] IndexedDB unavailable, falling back to in-memory storage:', error) + this.#useMemoryFallback = true + } + return this + } + + /** + * Opens or creates the IndexedDB database. + * @returns {Promise} + */ + #openDatabase () { + return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject(new Error('IndexedDB is not available')) + return + } + + const request = indexedDB.open(this.#dbName, DB_VERSION) + + request.onerror = () => { + reject(request.error) + } + + request.onsuccess = () => { + resolve(request.result) + } + + request.onupgradeneeded = (event) => { + /** @type {IDBDatabase} */ + const db = /** @type {IDBOpenDBRequest} */ (event.target).result + const oldVersion = event.oldVersion + + // Version 0 -> 1: Initial schema creation + if (oldVersion < 1) { + db.createObjectStore(STORE_NAME) + } + + // Future migrations: + // if (oldVersion < 2) { + // // Example: Add an index to the store + // const store = event.target.transaction.objectStore(STORE_NAME) + // store.createIndex('indexName', 'keyPath') + // } + } + }) + } + + /** + * Retrieve the stored key pair and nonce. + * @returns {Promise} + */ + async get () { + if (this.#useMemoryFallback) { + return this.#memoryStore + } + + if (!this.#db) { + throw new Error('DPoPStoreManager not initialized. Call init() first.') + } + + return new Promise((resolve, reject) => { + const transaction = /** @type {IDBDatabase} */ (this.#db).transaction(STORE_NAME, 'readonly') + const store = transaction.objectStore(STORE_NAME) + const request = store.get(STATE_KEY) + + request.onerror = () => { + reject(request.error) + } + + request.onsuccess = () => { + resolve(request.result) + } + }) + } + + /** + * Store or overwrite the key pair and optional nonce. + * @param {DPoPState} state - The state to store + * @returns {Promise} + */ + async set (state) { + if (this.#useMemoryFallback) { + this.#memoryStore = state + return + } + + if (!this.#db) { + throw new Error('DPoPStoreManager not initialized. Call init() first.') + } + + return new Promise((resolve, reject) => { + const transaction = /** @type {IDBDatabase} */ (this.#db).transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const request = store.put(state, STATE_KEY) + + request.onerror = () => { + reject(request.error) + } + + request.onsuccess = () => { + resolve() + } + }) + } + + /** + * Delete all stored data. Called on logout. + * @returns {Promise} + */ + async flush () { + if (this.#useMemoryFallback) { + this.#memoryStore = undefined + return + } + + if (!this.#db) { + throw new Error('DPoPStoreManager not initialized. Call init() first.') + } + + return new Promise((resolve, reject) => { + const transaction = /** @type {IDBDatabase} */ (this.#db).transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const request = store.delete(STATE_KEY) + + request.onerror = () => { + reject(request.error) + } + + request.onsuccess = () => { + resolve() + } + }) + } + + /** + * Update only the nonce while preserving existing keys. + * Convenience method for handling DPoP-Nonce header responses. + * @param {string} nonce - The new nonce from the authorization server + * @returns {Promise} + * @throws {Error} If nonce fails validation + */ + async updateNonce (nonce) { + // Validate nonce to prevent DoS and injection attacks + const MAX_NONCE_LENGTH = 512 + // RFC 9449 doesn't specify format, but nonces should be printable ASCII for HTTP headers + const VALID_NONCE_PATTERN = /^[\x21-\x7E]+$/ + + if (typeof nonce !== 'string' || nonce.length === 0) { + throw new Error('DPoP nonce must be a non-empty string') + } + if (nonce.length > MAX_NONCE_LENGTH) { + throw new Error(`DPoP nonce exceeds maximum length of ${MAX_NONCE_LENGTH} characters`) + } + if (!VALID_NONCE_PATTERN.test(nonce)) { + throw new Error('DPoP nonce contains invalid characters') + } + + const state = await this.get() + if (state) { + state.nonce = nonce + await this.set(state) + } + } + + /** + * Close the database connection. + */ + close () { + if (this.#db) { + this.#db.close() + this.#db = null + } + } +} + +export class DPoPSignatureProvider { + /** @type {BrowserSignatureAlgs} */ + #alg + /** @type {DPoPStoreManager} */ + #store + /** @type {string[]} */ + #serverAllowedAlgorithms + /** @type {Map} Resource server nonces keyed by origin */ + #resourceNonces = new Map() + /** @type {(() => number)|undefined} */ + #getTimeSkew + /** + * @param {DPoPSignatureProviderOptions} options + */ + constructor (options) { + const { + issuerUrl, + clientId, + serverSupportedAlgorithms, + requestedAlgorithm, + strictStorage = false, + getTimeSkew + } = options + this.#getTimeSkew = getTimeSkew + + // Check for Web Crypto API availability + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('DPoP requires Web Crypto API (crypto.subtle) which is not available in this environment.') + } + + // Validate required options + if (!issuerUrl) { + throw new Error('DPoP requires issuerUrl') + } + if (!clientId) { + throw new Error('DPoP requires clientId') + } + if (!serverSupportedAlgorithms || !Array.isArray(serverSupportedAlgorithms)) { + throw new Error('DPoP requires serverSupportedAlgorithms array') + } + this.#serverAllowedAlgorithms = serverSupportedAlgorithms + + let chosenAlgorithm = BrowserSignatureAlgs.ES256 // default value (due to high availability in browsers) + + if (requestedAlgorithm !== undefined) { + // Check to see if requestedAlgorithm is supported by the server + if (!this.#serverAllowedAlgorithms.includes(requestedAlgorithm)) { + throw new Error(`Requested algorithm '${requestedAlgorithm}' is not supported by the server. Server supports: ${this.#serverAllowedAlgorithms.join(', ')}`) + } + + chosenAlgorithm = requestedAlgorithm + } + + this.#alg = chosenAlgorithm + + this.#store = new DPoPStoreManager(issuerUrl.toString(), clientId, strictStorage) + } + + async init () { + await this.#store.init() + + // Check for existing keys or generate new ones + let state = await this.#store.get() + if (state) { + const expectedParams = /** @type {EcKeyGenParams & Algorithm} */ (KEY_GEN_PARAMS[this.#alg]) + const storedAlg = /** @type {EcKeyAlgorithm} */ (state.keys.publicKey.algorithm) + if (storedAlg.name !== expectedParams.name || (expectedParams.namedCurve && storedAlg.namedCurve !== expectedParams.namedCurve)) { + console.warn('[KEYCLOAK] Stored DPoP key algorithm does not match requested algorithm, regenerating key pair.') + await this.#store.flush() + state = undefined + } + } + if (!state) { + const keys = await this.#generateKeyPair() + state = { keys } + await this.#store.set(state) + } + } + + /** + * @returns {Promise} + */ + async #generateKeyPair () { + const params = KEY_GEN_PARAMS[this.#alg] + if (!params) { + throw new Error(`Unknown signature algorithm: ${this.#alg}`) + } + + try { + return /** @type {CryptoKeyPair} */ ( + await crypto.subtle.generateKey(params, false, ['sign']) + ) + } catch (ex) { + if (!(ex instanceof DOMException && ex.name === 'NotSupportedError')) { + throw ex + } + // Only EdDSA needs fallback; ECDSA is universally supported + if (this.#alg !== BrowserSignatureAlgs.EdDSA) { + throw ex + } + // Find the next available algorithm that both the browser and server support + const fallback = ECDSA_FALLBACK_ORDER.find(alg => + this.#serverAllowedAlgorithms.includes(alg) + ) + if (!fallback) { + throw new Error('No supported algorithm available in this browser') + } + this.#alg = fallback + return this.#generateKeyPair() + } + } + + /** + * @param {Uint8Array} msg + * @param {CryptoKey} key + * @returns {Promise} + */ + async #sign (msg, key) { + const params = SIGN_PARAMS[this.#alg] + if (!params) { + throw new Error(`Unknown signature algorithm: ${this.#alg}`) + } + return await crypto.subtle.sign(params, key, msg) + } + + /** + * Clear all stored DPoP state (keys and nonce) for this client. Called on logout. + * Only affects the keys for this specific issuer+clientId combination. + * @returns {Promise} + */ + async flush () { + await this.#store.flush() + } + + /** + * Get the stored authorization server nonce. + * @returns {Promise} + */ + async getAuthServerNonce () { + const state = await this.#store.get() + return state?.nonce + } + + /** + * Update the authorization server nonce. Called after receiving DPoP-Nonce header. + * @param {string} nonce + * @returns {Promise} + */ + async updateAuthServerNonce (nonce) { + await this.#store.updateNonce(nonce) + } + + /** + * Get the stored resource server nonce for a given origin. + * @param {string} origin - The resource server origin (e.g., "https://api.example.com") + * @returns {string | undefined} The stored nonce, or undefined if none exists + */ + getResourceServerNonce (origin) { + return this.#resourceNonces.get(origin) + } + + /** + * Update the resource server nonce for a given origin. Called after receiving DPoP-Nonce header. + * Silently ignores invalid nonces to prevent DoS attacks. + * @param {string} origin - The resource server origin + * @param {string} nonce - The nonce from the DPoP-Nonce response header + */ + updateResourceServerNonce (origin, nonce) { + // Validate nonce to prevent DoS and injection attacks + const MAX_NONCE_LENGTH = 512 + const VALID_NONCE_PATTERN = /^[\x21-\x7E]+$/ + + if (typeof nonce !== 'string' || nonce.length === 0) return + if (nonce.length > MAX_NONCE_LENGTH) return + if (!VALID_NONCE_PATTERN.test(nonce)) return + + this.#resourceNonces.set(origin, nonce) + } + + /** + * @param {string} url + * @param {string} httpMethod + * @param {string} [accessToken] Access token if calling resource server + * @param {string} [nonce] Server-provided nonce + * @returns {Promise} + */ + async generateDPoPProof (url, httpMethod, accessToken, nonce) { + const payload = { + jti: crypto.randomUUID(), + htm: httpMethod, + htu: (() => { + const urlObj = new URL(url) + return urlObj.origin + urlObj.pathname + })(), + iat: Math.floor(Date.now() / 1000) - (this.#getTimeSkew?.() ?? 0), + + ...(accessToken !== undefined && { + ath: base64UrlEncodeBuffer(await sha256Digest(accessToken)) + }), + + ...(nonce !== undefined && { nonce }) + } + + const state = await this.#store.get() + if (state === undefined) throw new Error('DPoP not initialized') + const exportedJwk = await crypto.subtle.exportKey('jwk', state.keys.publicKey) + const header = { + alg: this.#alg, + typ: 'dpop+jwt', + // Note: `y` is undefined for EdDSA/OKP keys (RFC 8037) but present for EC keys. + // JSON.stringify omits undefined values, so this produces valid JWKs for both. + jwk: { + crv: exportedJwk.crv, + kty: exportedJwk.kty, + x: exportedJwk.x, + y: exportedJwk.y + } + } + + const te = new TextEncoder() + const unsignedToken = `${base64UrlEncodeBuffer(te.encode(JSON.stringify(header)))}.${base64UrlEncodeBuffer(te.encode(JSON.stringify(payload)))}` + const signature = await this.#sign(te.encode(unsignedToken), state.keys.privateKey) + return `${unsignedToken}.${base64UrlEncodeBuffer(signature)}` + } +} + +/** + * @param {string} message + * @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#basic_example + */ +async function sha256Digest (message) { + const encoder = new TextEncoder() + const data = encoder.encode(message) + + if (typeof crypto === 'undefined' || typeof crypto.subtle === 'undefined') { + throw new Error('Web Crypto API is not available.') + } + + return await crypto.subtle.digest('SHA-256', data) +} + +/** + * @param {ArrayBuffer | Uint8Array} buffer + */ +function base64UrlEncodeBuffer (buffer) { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + const binary = String.fromCharCode(...bytes) + + return btoa(binary) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', '') +} diff --git a/lib/keycloak.d.ts b/lib/keycloak.d.ts index d8e549d..feb9d0b 100644 --- a/lib/keycloak.d.ts +++ b/lib/keycloak.d.ts @@ -18,6 +18,7 @@ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + export type KeycloakOnLoad = 'login-required' | 'check-sso' export type KeycloakResponseMode = 'query' | 'fragment' export type KeycloakResponseType = 'code' | 'id_token token' | 'code id_token token' @@ -49,19 +50,23 @@ export interface GenericOidcConfig { } /** - * OpenIdProviderMetadata The OpenID version of the adapter configuration, based on the {@link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata OpenID Connect Discovery specification}. + * OpenIdProviderMetadata The OpenID version of the adapter configuration, based on the {@link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata OpenID Connect Discovery specification} and {@link https://datatracker.ietf.org/doc/html/rfc9449#section-5.1 RFC 9449 Section 5.1} for DPoP support. */ export interface OpenIdProviderMetadata { + /** URL using the https scheme with no query or fragment component that the OP asserts as its Issuer Identifier. */ + issuer: string /** URL of the OP's OAuth 2.0 Authorization Endpoint. */ authorization_endpoint: string /** URL of the OP's OAuth 2.0 Token Endpoint. */ token_endpoint: string /** URL of the OP's UserInfo Endpoint. */ userinfo_endpoint?: string - /** URL of an OP iframe that supports cross-origin communications for session state information with the RP Client, using the HTML5 postMessage API. */ + /** URL of an OP iframe that supports cross-origin communications for session state information with the RP Client, using the HTML5 postMessage API. */ check_session_iframe?: string /** URL at the OP to which an RP can perform a redirect to request that the End-User be logged out at the OP. */ end_session_endpoint?: string + /** A JSON array containing a list of the JWS alg values (from the {@link https://www.iana.org/assignments/jose/jose.xhtml [IANA.JOSE.ALGS]} registry) supported by the authorization server for DPoP proof JWTs. */ + dpop_signing_alg_values_supported?: string[] } export type KeycloakConfig = KeycloakServerConfig | GenericOidcConfig @@ -228,6 +233,11 @@ export interface KeycloakInitOptions { * HTTP method for calling the end_session endpoint. Defaults to 'GET'. */ logoutMethod?: 'GET' | 'POST' + + /** + * Enables DPoP token auth flows and session key generation. + */ + useDPoP?: DPoPConfig } export interface KeycloakLoginOptions { @@ -387,6 +397,16 @@ export interface KeycloakUserInfo { [key: string]: any } +export interface DPoPConfig { + /** + * 'auto' setting will only use DPoP if the Keycloak instance advertises support, + * 'strict' setting will require the Keycloak instance to support DPoP. + */ + mode: 'auto' | 'strict' + /** Defaults to ES256 (P-256 curve) */ + alg?: 'ES256' | 'ES384' | 'ES512' | 'EdDSA' +} + /** * @deprecated Instead of importing 'KeycloakInstance' you can import 'Keycloak' directly as a type. */ @@ -682,6 +702,29 @@ declare class Keycloak { * @private Undocumented. */ loadUserInfo (): Promise + + /** + * Drop-in replacement for fetch that automatically handles DPoP authentication. + * If the request includes the KeycloakJS-managed Bearer token, it's replaced with + * DPoP authorization and proof. Also manages resource server nonces automatically. + * Otherwise, behaves like regular fetch. + * + * @example + * ```typescript + * // Call a DPoP-protected API endpoint + * const response = await keycloak.secureFetch('https://api.example.com/user', { + * headers: { + * 'Authorization': `Bearer ${keycloak.token}` + * } + * }); + * const data = await response.json(); + * ``` + * + * @param url The resource URL to fetch + * @param init Optional fetch init options (same as standard fetch) + * @returns A promise that resolves to the fetch Response + */ + secureFetch (url: URL | RequestInfo, init?: RequestInit): Promise } export default Keycloak diff --git a/lib/keycloak.js b/lib/keycloak.js index 1585586..3e12f48 100755 --- a/lib/keycloak.js +++ b/lib/keycloak.js @@ -59,6 +59,8 @@ export default class Keycloak { interval: 5 } + /** @type {import('./keycloak-dpop.js').DPoPSignatureProvider=} */ + #dpopProvider /** @type {KeycloakConfig} config */ #config didInitialize = false @@ -273,7 +275,34 @@ export default class Keycloak { this.messageReceiveTimeout = initOptions.messageReceiveTimeout } + if (initOptions.useDPoP) { + this.useDPoP = initOptions.useDPoP + } + await this.#loadConfig() + + if (this.useDPoP?.mode === 'strict' && (!this.dpopSigningAlgValuesSupported || this.dpopSigningAlgValuesSupported.length === 0)) { + throw new Error('DPoP is set to strict mode but the server does not advertise DPoP support (dpop_signing_alg_values_supported is missing or empty).') + } + + // Initialize DPoP if enabled and server supports it + if (this.useDPoP && this.dpopSigningAlgValuesSupported?.length) { + const issuerUrl = this.#getIssuerUrl() + if (!issuerUrl) throw new Error('Cannot initialize DPoP: issuer URL is not available. Ensure authServerUrl and realm are configured, or use OIDC provider mode with a valid issuer.') + if (!this.clientId) throw new Error('Cannot initialize DPoP: clientId is not configured.') + + const { DPoPSignatureProvider, BrowserSignatureAlgs } = await import('./keycloak-dpop.js') + this.#dpopProvider = new DPoPSignatureProvider({ + issuerUrl: new URL(issuerUrl), + clientId: this.clientId, + serverSupportedAlgorithms: this.dpopSigningAlgValuesSupported, + requestedAlgorithm: !this.useDPoP.alg ? BrowserSignatureAlgs.ES256 : BrowserSignatureAlgs[this.useDPoP.alg], + getTimeSkew: () => this.timeSkew ?? 0 + }) + await this.#dpopProvider.init() + this.#logInfo('[KEYCLOAK] DPoP initialized') + } + await this.#check3pCookiesSupported() await this.#processInit(initOptions) @@ -650,7 +679,7 @@ export default class Keycloak { this.authServerUrl = jsonConfig['auth-server-url'] this.realm = jsonConfig.realm this.clientId = jsonConfig.resource - this.#setupEndpoints() + await this.#setupEndpoints() } else { this.clientId = this.#config.clientId @@ -659,15 +688,31 @@ export default class Keycloak { } else { this.authServerUrl = this.#config.url this.realm = this.#config.realm - this.#setupEndpoints() + await this.#setupEndpoints() } } } /** - * @returns {void} + * @returns {Promise} */ - #setupEndpoints () { + async #setupEndpoints () { + // Fetch OIDC metadata to get DPoP supported algorithms (only if DPoP is enabled) + if (this.useDPoP) { + const issuerUrl = this.#getIssuerUrl() + if (!issuerUrl) { + this.#logWarn('[KEYCLOAK] Cannot fetch OIDC metadata: issuer URL is not available') + } else { + const url = `${stripTrailingSlash(issuerUrl)}/.well-known/openid-configuration` + try { + const openIdConfig = await fetchOpenIdConfig(url) + this.dpopSigningAlgValuesSupported = openIdConfig.dpop_signing_alg_values_supported ? openIdConfig.dpop_signing_alg_values_supported : undefined + } catch (error) { + this.#logWarn('[KEYCLOAK] Failed to fetch OIDC metadata for DPoP configuration: ' + (error instanceof Error ? error.message : error)) + } + } + } + this.endpoints = { authorize: () => { return this.#getRealmUrl() + '/protocol/openid-connect/auth' @@ -712,6 +757,9 @@ export default class Keycloak { * @returns {void} */ #setupOidcEndpoints (config) { + // Store DPoP supported algorithms if present + this.dpopSigningAlgValuesSupported = config.dpop_signing_alg_values_supported + this.issuer = config.issuer this.endpoints = { authorize () { return config.authorization_endpoint @@ -1150,7 +1198,7 @@ export default class Keycloak { if ((this.flow !== 'implicit') && code) { try { - const response = await fetchAccessToken(this.endpoints.token(), code, /** @type {string} */ (this.clientId), oauth.redirectUri, oauth.pkceCodeVerifier) + const response = await this.#fetchAccessToken(this.endpoints.token(), code, /** @type {string} */ (this.clientId), oauth.redirectUri, oauth.pkceCodeVerifier) authSuccess(response.access_token, response.refresh_token, response.id_token) if (this.flow === 'standard') { @@ -1286,7 +1334,8 @@ export default class Keycloak { * @param {KeycloakLogoutOptions} [options] * @returns {Promise} */ - logout = (options) => { + logout = async (options) => { + await this.#dpopProvider?.flush() return this.#adapter.logout(options) } @@ -1478,7 +1527,7 @@ export default class Keycloak { let timeLocal = new Date().getTime() try { - const response = await fetchRefreshToken(url, this.refreshToken, /** @type {string} */ (this.clientId)) + const response = await this.#fetchRefreshToken(url, this.refreshToken, /** @type {string} */ (this.clientId)) this.#logInfo('[KEYCLOAK] Token refreshed') timeLocal = (timeLocal + new Date().getTime()) / 2 @@ -1516,6 +1565,281 @@ export default class Keycloak { } } + secureFetch = async (url, init = {}) => { + const dpopProvider = this.#dpopProvider + if (dpopProvider && this.authenticated && this.token) { + const existingAuth = new Headers(init.headers).get('Authorization') + const isOurBearerToken = existingAuth === `Bearer ${this.token}` + + if (!isOurBearerToken) { + // Quick escape - didn't put this check in first if statement as it's more expensive than other checks + return fetch(url, init) + } + + const urlString = url instanceof URL ? url.href : url.toString() + const origin = new URL(urlString).origin + const method = init.method ?? 'GET' + const resourceNonce = dpopProvider.getResourceServerNonce(origin) + const proof = await dpopProvider.generateDPoPProof(urlString, method, this.token, resourceNonce) + const headers = new Headers(init.headers) + headers.set('Authorization', `DPoP ${this.token}`) + headers.set('DPoP', proof) + const resp = await fetch(url, { ...init, headers }) + + // Check for new nonce in response + const newNonce = resp.headers.get('DPoP-Nonce') + if (newNonce) { + dpopProvider.updateResourceServerNonce(origin, newNonce) + } + + // https://datatracker.ietf.org/doc/html/rfc9449#section-9 + // Resource servers signal via: WWW-Authenticate: DPoP error="use_dpop_nonce" + if (resp.status === 401 && newNonce) { + const wwwAuth = resp.headers.get('WWW-Authenticate') ?? '' + if (wwwAuth.includes('DPoP') && wwwAuth.includes('error="use_dpop_nonce"')) { + const retryProof = await dpopProvider.generateDPoPProof(urlString, method, this.token, newNonce) + const retryHeaders = new Headers(init.headers) + retryHeaders.set('Authorization', `DPoP ${this.token}`) + retryHeaders.set('DPoP', retryProof) + const retryResp = await fetch(url, { ...init, headers: retryHeaders }) + // Capture nonce from retry response for future requests + const retryNonce = retryResp.headers.get('DPoP-Nonce') + if (retryNonce) { + dpopProvider.updateResourceServerNonce(origin, retryNonce) + } + return retryResp + } + } + return resp + } else { + return fetch(url, init) + } + } + + /** + * @typedef {Object} AccessTokenResponse The successful token response from the authorization server, based on the {@link https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 OAuth 2.0 Authorization Framework specification}. + * @property {string} access_token The access token issued by the authorization server. + * @property {string} token_type The type of the token issued by the authorization server. + * @property {number} [expires_in] The lifetime in seconds of the access token. + * @property {string} [refresh_token] The refresh token issued by the authorization server. + * @property {string} [id_token] The ID token issued by the authorization server, if requested. + * @property {string} [scope] The scope of the access token. + */ + + /** + * Fetch the access token from the given URL. + * @param {string} url + * @param {string} code + * @param {string} clientId + * @param {string} redirectUri + * @param {string} [pkceCodeVerifier] + * @returns {Promise} + */ + async #fetchAccessToken (url, code, clientId, redirectUri, pkceCodeVerifier) { + const body = new URLSearchParams([ + ['code', code], + ['grant_type', 'authorization_code'], + ['client_id', clientId], + ['redirect_uri', redirectUri] + ]) + + if (pkceCodeVerifier) { + body.append('code_verifier', pkceCodeVerifier) + } + + if (this.#dpopProvider) { + return await this.#fetchDPoPAccessToken(url, body) + } else { + return await fetchJSON(url, { + method: 'POST', + credentials: 'include', + body + }) + } + } + + /** + * Validate token_type matches DPoP expectations. + * Per RFC 9449 Section 5: + * - If we sent DPoP proof but got Bearer, the token isn't bound (disable DPoP or error in strict mode) + * @param {AccessTokenResponse} tokenResponse + * @returns {AccessTokenResponse} The validated response + * @throws {Error} If strict mode and token_type doesn't match expectations + */ + #validateDPoPTokenType (tokenResponse) { + const tokenType = tokenResponse.token_type?.toLowerCase() + const hasDPoPProvider = !!this.#dpopProvider + + if (hasDPoPProvider && tokenType !== 'dpop') { + // We sent a DPoP proof but server returned non-DPoP token + if (this.useDPoP?.mode === 'strict') { + throw new Error('DPoP strict mode enabled but server returned non-DPoP token (token_type: ' + tokenResponse.token_type + ')') + } + // Auto mode: disable DPoP and fall back to Bearer + this.#logWarn('[KEYCLOAK] Server returned token_type "' + tokenResponse.token_type + '" instead of "DPoP". Disabling DPoP for this session.') + this.#dpopProvider = undefined + } + + return tokenResponse + } + + /** + * Fetch the access token with DPoP proof attached. + * Handles nonce requirements per RFC 9449 Section 8. + * @param {string} url + * @param {URLSearchParams} body + * @returns {Promise} + */ + async #fetchDPoPAccessToken (url, body) { + const dpopProvider = this.#dpopProvider + if (!dpopProvider) { + throw new Error('DPoP provider not initialized') + } + + try { + const nonce = await dpopProvider.getAuthServerNonce() + const proof = await dpopProvider.generateDPoPProof(url, 'POST', undefined, nonce) + const headers = new Headers() + headers.set('DPoP', proof) + headers.set('Accept', CONTENT_TYPE_JSON) + const resp = await fetchWithErrorHandling(url, { + method: 'POST', + credentials: 'include', + body, + headers + }) + // RFC 9449 Section 8.2: new nonce values may be returned in successful responses + const newNonce = resp.headers.get('DPoP-Nonce') + if (newNonce) { + await dpopProvider.updateAuthServerNonce(newNonce) + } + const tokenResponse = await resp.json() + return this.#validateDPoPTokenType(tokenResponse) + } catch (ex) { + // Check if this is a DPoP nonce error per RFC 9449 Section 8 + if (ex instanceof NetworkError && ex.response.status === 400) { + const dpopNonce = ex.response.headers.get('DPoP-Nonce') + if (dpopNonce) { + const errorBody = await ex.response.json().catch(() => ({})) + if (errorBody.error === 'use_dpop_nonce') { + // Store the nonce and retry with it + await dpopProvider.updateAuthServerNonce(dpopNonce) + const proof = await dpopProvider.generateDPoPProof(url, 'POST', undefined, dpopNonce) + const headers = new Headers() + headers.set('DPoP', proof) + headers.set('Accept', CONTENT_TYPE_JSON) + const retryResp = await fetchWithErrorHandling(url, { + method: 'POST', + credentials: 'include', + body, + headers + }) + // RFC 9449 Section 8.2: capture nonce from retry response as well + const retryNonce = retryResp.headers.get('DPoP-Nonce') + if (retryNonce) { + await dpopProvider.updateAuthServerNonce(retryNonce) + } + const tokenResponse = await retryResp.json() + return this.#validateDPoPTokenType(tokenResponse) + } + } + } + // Re-throw if not a use_dpop_nonce error + throw ex + } + } + + /** + * Fetch the refresh token from the given URL. + * @param {string} url + * @param {string} refreshToken + * @param {string} clientId + * @returns {Promise} + */ + async #fetchRefreshToken (url, refreshToken, clientId) { + const body = new URLSearchParams([ + ['grant_type', 'refresh_token'], + ['refresh_token', refreshToken], + ['client_id', clientId] + ]) + + if (this.#dpopProvider) { + return await this.#fetchDPoPRefreshToken(url, body) + } else { + return await fetchJSON(url, { + method: 'POST', + credentials: 'include', + body + }) + } + } + + /** + * Fetch the refresh token with DPoP proof attached. + * Handles nonce requirements per RFC 9449 Section 8. + * @param {string} url + * @param {URLSearchParams} body + * @returns {Promise} + */ + async #fetchDPoPRefreshToken (url, body) { + const dpopProvider = this.#dpopProvider + if (!dpopProvider) { + throw new Error('DPoP provider not initialized') + } + + try { + const nonce = await dpopProvider.getAuthServerNonce() + const proof = await dpopProvider.generateDPoPProof(url, 'POST', undefined, nonce) + const headers = new Headers() + headers.set('DPoP', proof) + headers.set('Accept', CONTENT_TYPE_JSON) + const resp = await fetchWithErrorHandling(url, { + method: 'POST', + credentials: 'include', + body, + headers + }) + // RFC 9449 Section 8.2: new nonce values may be returned in successful responses + const newNonce = resp.headers.get('DPoP-Nonce') + if (newNonce) { + await dpopProvider.updateAuthServerNonce(newNonce) + } + const tokenResponse = await resp.json() + return this.#validateDPoPTokenType(tokenResponse) + } catch (ex) { + // Check if this is a DPoP nonce error per RFC 9449 Section 8 + if (ex instanceof NetworkError && ex.response.status === 400) { + const dpopNonce = ex.response.headers.get('DPoP-Nonce') + if (dpopNonce) { + const errorBody = await ex.response.json().catch(() => ({})) + if (errorBody.error === 'use_dpop_nonce') { + // Store the nonce and retry with it + await dpopProvider.updateAuthServerNonce(dpopNonce) + const proof = await dpopProvider.generateDPoPProof(url, 'POST', undefined, dpopNonce) + const headers = new Headers() + headers.set('DPoP', proof) + headers.set('Accept', CONTENT_TYPE_JSON) + const retryResp = await fetchWithErrorHandling(url, { + method: 'POST', + credentials: 'include', + body, + headers + }) + // RFC 9449 Section 8.2: capture nonce from retry response as well + const retryNonce = retryResp.headers.get('DPoP-Nonce') + if (retryNonce) { + await dpopProvider.updateAuthServerNonce(retryNonce) + } + const tokenResponse = await retryResp.json() + return this.#validateDPoPTokenType(tokenResponse) + } + } + } + // Re-throw if not a use_dpop_nonce error + throw ex + } + } + /** * @param {string} [token] * @param {string} [refreshToken] @@ -1592,6 +1916,21 @@ export default class Keycloak { return `${stripTrailingSlash(this.authServerUrl)}/realms/${encodeURIComponent(/** @type {string} */ (this.realm))}` } + /** + * Gets the issuer URL. + * Returns the OIDC issuer if available, otherwise constructs from realm URL. + * @returns {string=} + */ + #getIssuerUrl () { + // For OIDC provider mode, use the issuer from metadata + if (this.issuer) { + return this.issuer + } + + // For Keycloak mode, use the realm URL + return this.#getRealmUrl() + } + /** * @param {Function} fn * @returns {(message: string) => void} @@ -2041,65 +2380,6 @@ async function fetchOpenIdConfig (url) { return await fetchJSON(url) } -/** - * @typedef {Object} AccessTokenResponse The successful token response from the authorization server, based on the {@link https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 OAuth 2.0 Authorization Framework specification}. - * @property {string} access_token The access token issued by the authorization server. - * @property {string} token_type The type of the token issued by the authorization server. - * @property {number} [expires_in] The lifetime in seconds of the access token. - * @property {string} [refresh_token] The refresh token issued by the authorization server. - * @property {string} [id_token] The ID token issued by the authorization server, if requested. - * @property {string} [scope] The scope of the access token. - */ - -/** - * Fetch the access token from the given URL. - * @param {string} url - * @param {string} code - * @param {string} clientId - * @param {string} redirectUri - * @param {string} [pkceCodeVerifier] - * @returns {Promise} - */ -async function fetchAccessToken (url, code, clientId, redirectUri, pkceCodeVerifier) { - const body = new URLSearchParams([ - ['code', code], - ['grant_type', 'authorization_code'], - ['client_id', clientId], - ['redirect_uri', redirectUri] - ]) - - if (pkceCodeVerifier) { - body.append('code_verifier', pkceCodeVerifier) - } - - return await fetchJSON(url, { - method: 'POST', - credentials: 'include', - body - }) -} - -/** - * Fetch the refresh token from the given URL. - * @param {string} url - * @param {string} refreshToken - * @param {string} clientId - * @returns {Promise} - */ -async function fetchRefreshToken (url, refreshToken, clientId) { - const body = new URLSearchParams([ - ['grant_type', 'refresh_token'], - ['refresh_token', refreshToken], - ['client_id', clientId] - ]) - - return await fetchJSON(url, { - method: 'POST', - credentials: 'include', - body - }) -} - /** * @template [T=unknown] * @param {string} url diff --git a/test/tests/dpop.spec.ts b/test/tests/dpop.spec.ts new file mode 100644 index 0000000..92deab8 --- /dev/null +++ b/test/tests/dpop.spec.ts @@ -0,0 +1,697 @@ +import { expect } from '@playwright/test' +import type { KeycloakInitOptions } from '../../lib/keycloak.d.ts' +import { createTestBed, test } from '../support/testbed.ts' + +test('logs in and out with DPoP enabled (auto mode)', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client.. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto' } + } + + // Track DPoP requests to the token endpoint + let tokenRequestWithDPoP = false + let tokenResponseType: string | null = null + + // Set up interceptor + await page.route('**/protocol/openid-connect/token', async (route) => { + const request = route.request() + const headers = request.headers() + + // Verify DPoP header is present + if (headers.dpop !== undefined) { + tokenRequestWithDPoP = true + + // Verify DPoP proof is a valid JWT (has 3 parts separated by dots) + const dpopProof = headers.dpop + const parts = dpopProof.split('.') + expect(parts.length).toBe(3) // JWT should have header.payload.signature + } + + // Continue the request and capture the response + const response = await route.fetch() + const responseBody = await response.text() + + // Parse the token response to check token_type + try { + const tokenResponse = JSON.parse(responseBody) + tokenResponseType = tokenResponse.token_type + + // Verify token_type is "DPoP" when DPoP is enabled + expect(tokenResponse.token_type.toLowerCase()).toBe('dpop') + } catch (error) { + // If parsing fails, just continue + } + + // Return the response to the browser + await route.fulfill({ + response, + body: responseBody + }) + }) + + // Initially, no user should be authenticated. + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + expect(await executor.isAuthenticated()).toBe(false) + await executor.login() + await executor.submitLoginForm() + // After triggering a login, the user should be authenticated. + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Verify that DPoP was actually used during token acquisition + expect(tokenRequestWithDPoP).toBe(true) + expect(tokenResponseType).not.toBeNull() + expect(String(tokenResponseType).toLowerCase()).toBe('dpop') + + await executor.logout() + // After logging out, the user should no longer be authenticated. + expect(await executor.initializeAdapter(initOptions)).toBe(false) + expect(await executor.isAuthenticated()).toBe(false) +}) + +test('logs in with DPoP in strict mode', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client.. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'strict' } + } + + let tokenRequestWithDPoP = false + let tokenResponseType: string | null = null + + await page.route('**/protocol/openid-connect/token', async (route) => { + const request = route.request() + const headers = request.headers() + + if (headers.dpop !== undefined) { + tokenRequestWithDPoP = true + const dpopProof = headers.dpop + const parts = dpopProof.split('.') + expect(parts.length).toBe(3) + } + + const response = await route.fetch() + const responseBody = await response.text() + + try { + const tokenResponse = JSON.parse(responseBody) + tokenResponseType = tokenResponse.token_type + expect(tokenResponse.token_type.toLowerCase()).toBe('dpop') + } catch (error) { + // If parsing fails, just continue + } + + await route.fulfill({ + response, + body: responseBody + }) + }) + + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + expect(await executor.isAuthenticated()).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Verify DPoP was used + expect(tokenRequestWithDPoP).toBe(true) + expect(tokenResponseType).not.toBeNull() + expect(String(tokenResponseType).toLowerCase()).toBe('dpop') +}) + +test('logs in with DPoP using ES256 algorithm', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client.. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto', alg: 'ES256' } + } + + let dpopAlgorithm: string | null = null + let tokenRequestWithDPoP = false + + await page.route('**/protocol/openid-connect/token', async (route) => { + const request = route.request() + const headers = request.headers() + + if (headers.dpop !== undefined) { + tokenRequestWithDPoP = true + const dpopProof = headers.dpop + const parts = dpopProof.split('.') + expect(parts.length).toBe(3) + + // Decode the header to verify algorithm + try { + const header = JSON.parse(atob(parts[0])) + dpopAlgorithm = header.alg + expect(header.alg).toBe('ES256') + expect(header.typ).toBe('dpop+jwt') + } catch (error) { + // If parsing fails, just continue + } + } + + const response = await route.fetch() + const responseBody = await response.text() + + await route.fulfill({ + response, + body: responseBody + }) + }) + + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Verify ES256 algorithm was used + expect(tokenRequestWithDPoP).toBe(true) + expect(dpopAlgorithm).toBe('ES256') +}) + +test('logs in with DPoP using EdDSA algorithm', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client.. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto', alg: 'EdDSA' } + } + + let dpopAlgorithm: string | null = null + let tokenRequestWithDPoP = false + + await page.route('**/protocol/openid-connect/token', async (route) => { + const request = route.request() + const headers = request.headers() + + if (headers.dpop !== undefined) { + tokenRequestWithDPoP = true + const dpopProof = headers.dpop + const parts = dpopProof.split('.') + expect(parts.length).toBe(3) + + // Decode the header to verify algorithm + try { + const header = JSON.parse(atob(parts[0])) + dpopAlgorithm = header.alg + expect(header.alg).toBe('EdDSA') + expect(header.typ).toBe('dpop+jwt') + } catch (error) { + // If parsing fails, just continue + } + } + + const response = await route.fetch() + const responseBody = await response.text() + + await route.fulfill({ + response, + body: responseBody + }) + }) + + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Verify EdDSA algorithm was used + expect(tokenRequestWithDPoP).toBe(true) + expect(dpopAlgorithm).toBe('EdDSA') +}) + +test('refreshes tokens with DPoP', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client.. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto' } + } + + let tokenRequestCount = 0 + let refreshRequestWithDPoP = false + + await page.route('**/protocol/openid-connect/token', async (route) => { + const request = route.request() + const headers = request.headers() + const postData = request.postData() + + tokenRequestCount++ + + // Check if this is a refresh token request + const isRefreshRequest = postData?.includes('grant_type=refresh_token') ?? false + + if (isRefreshRequest && (headers.dpop !== undefined)) { + refreshRequestWithDPoP = true + const dpopProof = headers.dpop + const parts = dpopProof.split('.') + expect(parts.length).toBe(3) + } + + const response = await route.fetch() + const responseBody = await response.text() + + await route.fulfill({ + response, + body: responseBody + }) + }) + + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Force a token refresh + const refreshed = await executor.updateToken(-1) + expect(refreshed).toBe(true) + + // Verify that DPoP was used for the refresh request + expect(tokenRequestCount).toBeGreaterThanOrEqual(2) // Initial token + refresh + expect(refreshRequestWithDPoP).toBe(true) +}) + +test('generates new DPoP key for each login session', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client.. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto' } + } + + let firstSessionJwk: string | null = null + let secondSessionJwk: string | null = null + + await page.route('**/protocol/openid-connect/token', async (route) => { + const request = route.request() + const headers = request.headers() + + if (headers.dpop !== undefined) { + const dpopProof = headers.dpop + const parts = dpopProof.split('.') + + try { + // Decode the DPoP JWT header to extract the public key + const header = JSON.parse(atob(parts[0])) + const jwkString = JSON.stringify(header.jwk) + + // Capture the JWK from the first session + if (firstSessionJwk === null) { + firstSessionJwk = jwkString + } else if (secondSessionJwk === null) { + // Capture the JWK from the second session (after logout/login) + secondSessionJwk = jwkString + } + } catch (error) { + // If parsing fails, just continue + } + } + + const response = await route.fetch() + const responseBody = await response.text() + + await route.fulfill({ + response, + body: responseBody + }) + }) + + // First session: login + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Verify first session has a DPoP key + expect(firstSessionJwk).not.toBeNull() + + // Logout + await executor.logout() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + expect(await executor.isAuthenticated()).toBe(false) + + // Second session: login again + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Verify second session has a different DPoP key + expect(secondSessionJwk).not.toBeNull() + expect(secondSessionJwk).not.toBe(firstSessionJwk) +}) + +test('logs in with OIDC provider configuration', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient, realm } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client.. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + + // Use OIDC provider configuration instead of standard Keycloak config + const oidcProviderUrl = `${authServerUrl.origin}/realms/${realm}` + const oidcConfig = { + clientId: executor.defaultConfig().clientId, + oidcProvider: oidcProviderUrl + } + + await executor.navigateToApp() + await executor.instantiateAdapter(oidcConfig) + + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto' } + } + + let tokenRequestWithDPoP = false + let tokenResponseType: string | null = null + + await page.route('**/protocol/openid-connect/token', async (route) => { + const request = route.request() + const headers = request.headers() + + if (headers.dpop !== undefined) { + tokenRequestWithDPoP = true + const dpopProof = headers.dpop + const parts = dpopProof.split('.') + expect(parts.length).toBe(3) // JWT should have header.payload.signature + } + + const response = await route.fetch() + const responseBody = await response.text() + + try { + const tokenResponse = JSON.parse(responseBody) + tokenResponseType = tokenResponse.token_type + expect(tokenResponse.token_type.toLowerCase()).toBe('dpop') + } catch (error) { + // If parsing fails, just continue + } + + await route.fulfill({ + response, + body: responseBody + }) + }) + + // Initialize and login + expect(await executor.initializeAdapter(initOptions)).toBe(false) + expect(await executor.isAuthenticated()).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Verify DPoP was used with OIDC provider config + expect(tokenRequestWithDPoP).toBe(true) + expect(tokenResponseType).not.toBeNull() + expect(String(tokenResponseType).toLowerCase()).toBe('dpop') + + await executor.logout() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + expect(await executor.isAuthenticated()).toBe(false) +}) + +test('calls DPoP-protected resources with secureFetch', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto' } + } + + // Login with DPoP + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // First, try regular fetch with Bearer token (should fail because token is DPoP-bound) + const regularFetchResponse = await page.evaluate(async () => { + const keycloak = (globalThis as any).keycloak + const userInfoUrl = keycloak.endpoints.userinfo() + + const resp = await fetch(userInfoUrl, { + headers: { + Authorization: `Bearer ${String(keycloak.token)}` + } + }) + + return { + status: resp.status, + ok: resp.ok + } + }) + + // Verify regular fetch failed (DPoP-bound token requires DPoP proof) + expect(regularFetchResponse.ok).toBe(false) + expect(regularFetchResponse.status).toBe(401) + + // Now use secureFetch with Bearer token (should succeed because secureFetch adds DPoP proof) + const secureFetchResponse = await page.evaluate(async () => { + const keycloak = (globalThis as any).keycloak + const userInfoUrl = keycloak.endpoints.userinfo() + const resp = await keycloak.secureFetch(userInfoUrl, { + headers: { + Authorization: `Bearer ${String(keycloak.token)}` + } + }) + const data = await resp.json() + return { + status: resp.status, + data + } + }) + + // Verify secureFetch succeeded + expect(secureFetchResponse.status).toBe(200) + expect(secureFetchResponse.data).toBeTruthy() +}) + +test('secureFetch calls open endpoints without DPoP when no Authorization header provided', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient, realm } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto' } + } + + let dpopHeaderSent = false + + // Intercept requests to the OIDC discovery endpoint (open endpoint, no auth required). + const discoveryUrl = `${authServerUrl.origin}/realms/${realm}/.well-known/openid-configuration` + await page.route('**/.well-known/openid-configuration', async (route) => { + const headers = route.request().headers() + + // Check if DPoP header was sent. + if (headers.dpop !== undefined) { + dpopHeaderSent = true + } + + // Let the request go through. + await route.continue() + }) + + // Login with DPoP. + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Use secureFetch to call an open endpoint without Authorization header. + const response = await page.evaluate(async (url) => { + const keycloak = (globalThis as any).keycloak + const resp = await keycloak.secureFetch(url) + const data = await resp.json() + return { + status: resp.status, + hasIssuer: data.issuer !== undefined + } + }, discoveryUrl) + + // Verify request succeeded. + expect(response.status).toBe(200) + expect(response.hasIssuer).toBe(true) + + // Verify NO DPoP header was sent (no Authorization header = no DPoP). + expect(dpopHeaderSent).toBe(false) +}) + +test('secureFetch includes correct HTTP method in DPoP proof', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto' } + } + + const capturedProofs: Array<{ method: string, proof: string }> = [] + + // Intercept requests to userinfo endpoint. + await page.route('**/protocol/openid-connect/userinfo', async (route) => { + const dpopHeader = route.request().headers().dpop + const method = route.request().method() + + if (dpopHeader !== undefined) { + capturedProofs.push({ method, proof: dpopHeader }) + } + + // For non-GET methods, return a mock success response. + if (method !== 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sub: 'test-user' }) + }) + } else { + // Let GET requests go through to Keycloak. + await route.continue() + } + }) + + // Login with DPoP. + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Test GET, POST, PUT, DELETE methods. + const methods = ['GET', 'POST', 'PUT', 'DELETE'] as const + + for (const method of methods) { + await page.evaluate(async (testMethod) => { + const keycloak = (globalThis as any).keycloak + const userInfoUrl = keycloak.endpoints.userinfo() + + await keycloak.secureFetch(userInfoUrl, { + method: testMethod, + headers: { + Authorization: `Bearer ${String(keycloak.token)}`, + 'Content-Type': 'application/json' + }, + body: testMethod !== 'GET' ? JSON.stringify({}) : undefined + }) + }, method) + } + + // Verify we captured proofs for all methods. + expect(capturedProofs.length).toBe(4) + + // Verify each proof has the correct htm (HTTP method) claim. + for (let i = 0; i < methods.length; i++) { + const { method, proof } = capturedProofs[i] + const parts = proof.split('.') + expect(parts.length).toBe(3) + + const payload = JSON.parse(atob(parts[1])) + expect(payload.htm).toBe(method) + expect(payload.htu).toContain('userinfo') + } +}) + +test('handles concurrent secureFetch calls correctly', async ({ page, appUrl, authServerUrl }) => { + const { executor, updateClient } = await createTestBed(page, { appUrl, authServerUrl }) + // Enable DPoP on the client. + await updateClient({ attributes: { 'dpop.bound.access.tokens': 'true' } }) + + const initOptions: KeycloakInitOptions = { + ...executor.defaultInitOptions(), + useDPoP: { mode: 'auto' } + } + + const capturedJtis: Set = new Set() + const capturedProofs: string[] = [] + + // Intercept requests to userinfo endpoint. + await page.route('**/protocol/openid-connect/userinfo', async (route) => { + const dpopHeader = route.request().headers().dpop + + if (dpopHeader !== undefined) { + capturedProofs.push(dpopHeader) + + const parts = dpopHeader.split('.') + const payload = JSON.parse(atob(parts[1])) + capturedJtis.add(payload.jti) + } + + // Let the request go through. + await route.continue() + }) + + // Login with DPoP. + await executor.navigateToApp() + expect(await executor.initializeAdapter(initOptions)).toBe(false) + await executor.login() + await executor.submitLoginForm() + expect(await executor.initializeAdapter(initOptions)).toBe(true) + expect(await executor.isAuthenticated()).toBe(true) + + // Make 5 concurrent secureFetch calls. + const responses = await page.evaluate(async () => { + const keycloak = (globalThis as any).keycloak + const userInfoUrl = keycloak.endpoints.userinfo() + + const promises = Array(5).fill(null).map(() => + keycloak.secureFetch(userInfoUrl, { + headers: { + Authorization: `Bearer ${String(keycloak.token)}` + } + }).then((resp: Response) => resp.status) + ) + + return await Promise.all(promises) + }) + + // Verify all requests succeeded. + expect(responses.length).toBe(5) + responses.forEach(status => { + expect(status).toBe(200) + }) + + // Verify we captured 5 DPoP proofs. + expect(capturedProofs.length).toBe(5) + + // Verify each proof has a unique jti (replay protection). + expect(capturedJtis.size).toBe(5) + + // Verify all proofs use the same JWK (same session). + const jwks = capturedProofs.map(proof => { + const parts = proof.split('.') + const header = JSON.parse(atob(parts[0])) + return JSON.stringify(header.jwk) + }) + + const uniqueJwks = new Set(jwks) + expect(uniqueJwks.size).toBe(1) +})