From 8f3940c42ad62264abfd61690777b602e0bdc31a Mon Sep 17 00:00:00 2001 From: Guillaume Chervet Date: Thu, 22 Feb 2024 21:36:01 +0100 Subject: [PATCH] feat(oidc): dpop inside serviceworker (#1306) (release) * feat(oidc): dpop inside serviceworker * test * update * update * update * update --- .../public/OidcTrustedDomains.js | 4 +- .../public/staticwebapp.config.json | 9 + examples/react-oidc-demo/src/Home.tsx | 8 +- examples/react-oidc-demo/vite.config.js | 2 +- .../src/OidcServiceWorker.ts | 65 +++-- .../oidc-client-service-worker/src/crypto.ts | 20 ++ .../oidc-client-service-worker/src/dpop.ts | 22 ++ .../oidc-client-service-worker/src/jwt.ts | 267 ++++++++++++++++++ .../oidc-client-service-worker/src/types.ts | 12 + .../src/utils/__tests__/domains.spec.ts | 1 + .../src/utils/__tests__/testHelper.ts | 2 + .../src/utils/__tests__/tokens.spec.ts | 2 +- .../src/utils/domains.ts | 2 + .../src/utils/tokens.ts | 17 +- packages/oidc-client/README.md | 26 +- packages/oidc-client/src/jwt.ts | 26 +- packages/oidc-client/src/login.ts | 6 +- packages/oidc-client/src/oidc.ts | 18 +- packages/react-oidc/README.md | 24 +- 19 files changed, 474 insertions(+), 59 deletions(-) create mode 100644 packages/oidc-client-service-worker/src/crypto.ts create mode 100644 packages/oidc-client-service-worker/src/dpop.ts create mode 100644 packages/oidc-client-service-worker/src/jwt.ts diff --git a/examples/react-oidc-demo/public/OidcTrustedDomains.js b/examples/react-oidc-demo/public/OidcTrustedDomains.js index 416c4626a..e7b4ee1c1 100644 --- a/examples/react-oidc-demo/public/OidcTrustedDomains.js +++ b/examples/react-oidc-demo/public/OidcTrustedDomains.js @@ -22,5 +22,7 @@ trustedDomains.config_separate_oidc_access_token_domains = { accessTokenDomains: ["https://myapi"] }; -trustedDomains.config_with_dpop = { domains: ["https://demo.duendesoftware.com"], showAccessToken: true }; +trustedDomains.config_with_dpop = { + domains: ["https://demo.duendesoftware.com"], + demonstratingProofOfPossession: true }; //# sourceMappingURL=OidcTrustedDomains.js.map \ No newline at end of file diff --git a/examples/react-oidc-demo/public/staticwebapp.config.json b/examples/react-oidc-demo/public/staticwebapp.config.json index ba338eae6..5877917f4 100644 --- a/examples/react-oidc-demo/public/staticwebapp.config.json +++ b/examples/react-oidc-demo/public/staticwebapp.config.json @@ -2,5 +2,14 @@ "navigationFallback": { "rewrite": "index.html", "exclude": ["*.{svg,png,jpg,gif}","*.{css,scss}","*.js"] + }, + "globalHeaders": { + "content-security-policy": "script-src 'self'", + "Access-Control-Allow-Origin": "*", + "X-Frame-Options": "SAMEORIGIN", + "X-Permitted-Cross-Domain-Policies": "none", + "Referrer-Policy":"no-referrer", + "X-Content-Type-Options": "nosniff", + "Permissions-Policy": "autoplay=()" } } \ No newline at end of file diff --git a/examples/react-oidc-demo/src/Home.tsx b/examples/react-oidc-demo/src/Home.tsx index 118e9b7ee..b8c035158 100644 --- a/examples/react-oidc-demo/src/Home.tsx +++ b/examples/react-oidc-demo/src/Home.tsx @@ -3,12 +3,12 @@ import React, {useEffect} from 'react'; import {useNavigate} from "react-router-dom"; -/*const createIframeHack =() => { + const createIframeHack =() => { const iframe = document.createElement('iframe'); const html = 'Foo'; iframe.srcdoc = html; document.body.appendChild(iframe); -}*/ +} export const Home = () => { const { login, logout, renewTokens, isAuthenticated } = useOidc(); @@ -18,9 +18,9 @@ export const Home = () => { navigate("/profile"); }; - /*useEffect(() => { + useEffect(() => { createIframeHack(); - }, []);*/ + }, []); return ( diff --git a/examples/react-oidc-demo/vite.config.js b/examples/react-oidc-demo/vite.config.js index 9fd73d00c..197494e22 100644 --- a/examples/react-oidc-demo/vite.config.js +++ b/examples/react-oidc-demo/vite.config.js @@ -12,7 +12,7 @@ export default defineConfig({ }, server: { headers: { - // "Content-Security-Policy": "script-src 'self' 'unsafe-inline';", + //"Content-Security-Policy": "script-src 'unsafe-inline' https://www.google-analitics.com;", }, }, }); diff --git a/packages/oidc-client-service-worker/src/OidcServiceWorker.ts b/packages/oidc-client-service-worker/src/OidcServiceWorker.ts index 26eef6755..22271eb52 100644 --- a/packages/oidc-client-service-worker/src/OidcServiceWorker.ts +++ b/packages/oidc-client-service-worker/src/OidcServiceWorker.ts @@ -17,6 +17,9 @@ import { import {extractConfigurationNameFromCodeVerifier, replaceCodeVerifier} from './utils/codeVerifier'; import { normalizeUrl } from './utils/normalizeUrl'; import version from './version'; +import {generateJwkAsync, generateJwtDemonstratingProofOfPossessionAsync} from "./jwt"; +import {getDpopConfiguration} from "./dpop"; +import {base64urlOfHashOfASCIIEncodingAsync} from "./crypto"; // @ts-ignore if (typeof trustedTypes !== 'undefined' && typeof trustedTypes.createPolicy == 'function') { @@ -92,6 +95,19 @@ const keepAliveAsync = async (event: FetchEvent) => { return response; }; +async function generateDpopAsync(originalRequest: Request, currentDatabase:OidcConfig|null, url: string, extrasClaims={} ) { + const headersExtras = serializeHeaders(originalRequest.headers); + if (currentDatabase && currentDatabase.demonstratingProofOfPossessionConfiguration && currentDatabase.demonstratingProofOfPossessionJwkJson) { + const dpopConfiguration = currentDatabase.demonstratingProofOfPossessionConfiguration; + const jwk = currentDatabase.demonstratingProofOfPossessionJwkJson; + headersExtras['dpop'] = await generateJwtDemonstratingProofOfPossessionAsync(self)(dpopConfiguration)(jwk, 'POST', url, extrasClaims); + if(currentDatabase.demonstratingProofOfPossessionNonce != null) { + headersExtras['nonce'] = currentDatabase.demonstratingProofOfPossessionNonce; + } + } + return headersExtras; +} + const handleFetch = async (event: FetchEvent) => { const originalRequest = event.request; const url = normalizeUrl(originalRequest.url); @@ -176,16 +192,18 @@ const handleFetch = async (event: FetchEvent) => { if (numberDatabase > 0) { const maPromesse = new Promise((resolve, reject) => { const clonedRequest = originalRequest.clone(); - const response = clonedRequest.text().then((actualBody) => { + const response = clonedRequest.text().then(async (actualBody) => { if ( actualBody.includes(TOKEN.REFRESH_TOKEN) || actualBody.includes(TOKEN.ACCESS_TOKEN) ) { + let headers = serializeHeaders(originalRequest.headers); let newBody = actualBody; for (let i = 0; i < numberDatabase; i++) { const currentDb = currentDatabases[i]; - if (currentDb && currentDb.tokens != null) { + const claimsExtras = {ath: await base64urlOfHashOfASCIIEncodingAsync(currentDb.tokens.access_token),}; + headers = await generateDpopAsync(originalRequest, currentDb, url, claimsExtras); const keyRefreshToken = TOKEN.REFRESH_TOKEN + '_' + currentDb.configurationName; if (actualBody.includes(keyRefreshToken)) { @@ -194,6 +212,7 @@ const handleFetch = async (event: FetchEvent) => { encodeURIComponent(currentDb.tokens.refresh_token as string), ); currentDatabase = currentDb; + break; } const keyAccessToken = @@ -208,11 +227,12 @@ const handleFetch = async (event: FetchEvent) => { } } } + const fetchPromise = fetch(originalRequest, { body: newBody, method: clonedRequest.method, headers: { - ...serializeHeaders(originalRequest.headers), + ...headers, }, mode: clonedRequest.mode, cache: clonedRequest.cache, @@ -254,12 +274,14 @@ const handleFetch = async (event: FetchEvent) => { currentDatabase.codeVerifier, ); } - + + const headersExtras = await generateDpopAsync(originalRequest, currentDatabase, url); + return fetch(originalRequest, { body: newBody, method: clonedRequest.method, headers: { - ...serializeHeaders(originalRequest.headers), + ...headersExtras, }, mode: clonedRequest.mode, cache: clonedRequest.cache, @@ -301,7 +323,7 @@ const handleFetch = async (event: FetchEvent) => { } }; -const handleMessage = (event: ExtendableMessageEvent) => { +const handleMessage = async (event: ExtendableMessageEvent) => { const port = event.ports[0]; const data = event.data as MessageEventData; if (event.data.type === 'claim') { @@ -340,6 +362,7 @@ const handleMessage = (event: ExtendableMessageEvent) => { convertAllRequestsToCorsExceptNavigate ?? false, demonstratingProofOfPossessionNonce: null, demonstratingProofOfPossessionJwkJson: null, + demonstratingProofOfPossessionConfiguration: null, }; currentDatabase = database[configurationName]; @@ -347,11 +370,15 @@ const handleMessage = (event: ExtendableMessageEvent) => { trustedDomains[configurationName] = []; } } + switch (data.type) { case 'clear': currentDatabase.tokens = null; currentDatabase.state = null; currentDatabase.codeVerifier = null; + currentDatabase.demonstratingProofOfPossessionNonce = null; + currentDatabase.demonstratingProofOfPossessionJwkJson = null; + currentDatabase.demonstratingProofOfPossessionConfiguration = null; currentDatabase.status = data.data.status; port.postMessage({ configurationName }); return; @@ -372,6 +399,17 @@ const handleMessage = (event: ExtendableMessageEvent) => { currentDatabase.oidcServerConfiguration = oidcServerConfiguration; currentDatabase.oidcConfiguration = data.data.oidcConfiguration; + if(currentDatabase.demonstratingProofOfPossessionConfiguration == null ){ + const demonstratingProofOfPossessionConfiguration = getDpopConfiguration(trustedDomains[configurationName]); + if(demonstratingProofOfPossessionConfiguration != null){ + if(currentDatabase.oidcConfiguration.demonstrating_proof_of_possession){ + console.warn("In service worker, demonstrating_proof_of_possession must be configured from trustedDomains file") + } + currentDatabase.demonstratingProofOfPossessionConfiguration = demonstratingProofOfPossessionConfiguration; + currentDatabase.demonstratingProofOfPossessionJwkJson = await generateJwkAsync(self)(demonstratingProofOfPossessionConfiguration.generateKeyAlgorithm); + } + } + if (!currentDatabase.tokens) { port.postMessage({ tokens: null, @@ -421,21 +459,6 @@ const handleMessage = (event: ExtendableMessageEvent) => { }); return; } - case 'setDemonstratingProofOfPossessionJwk': { - currentDatabase.demonstratingProofOfPossessionJwkJson = - data.data.demonstratingProofOfPossessionJwkJson; - port.postMessage({ configurationName }); - return; - } - case 'getDemonstratingProofOfPossessionJwk': { - const demonstratingProofOfPossessionJwkJson = - currentDatabase.demonstratingProofOfPossessionJwkJson; - port.postMessage({ - configurationName, - demonstratingProofOfPossessionJwkJson, - }); - return; - } case 'setState': { currentDatabase.state = data.data.state; port.postMessage({ configurationName }); diff --git a/packages/oidc-client-service-worker/src/crypto.ts b/packages/oidc-client-service-worker/src/crypto.ts new file mode 100644 index 000000000..e68d89c9d --- /dev/null +++ b/packages/oidc-client-service-worker/src/crypto.ts @@ -0,0 +1,20 @@ +import {uint8ToUrlBase64} from "./jwt"; + + +export function textEncodeLite(str: string) { + const buf = new ArrayBuffer(str.length); + const bufView = new Uint8Array(buf); + + for (let i = 0; i < str.length; i++) { + bufView[i] = str.charCodeAt(i); + } + return bufView; +} + +export function base64urlOfHashOfASCIIEncodingAsync(code: string):Promise { + return new Promise((resolve, reject) => { + crypto.subtle.digest('SHA-256', textEncodeLite(code)).then(buffer => { + return resolve(uint8ToUrlBase64(new Uint8Array(buffer))); + }, error => reject(error)); + }); +} diff --git a/packages/oidc-client-service-worker/src/dpop.ts b/packages/oidc-client-service-worker/src/dpop.ts new file mode 100644 index 000000000..dfa516d1e --- /dev/null +++ b/packages/oidc-client-service-worker/src/dpop.ts @@ -0,0 +1,22 @@ +import {Domain, DomainDetails} from "./types.js"; +import {defaultDemonstratingProofOfPossessionConfiguration} from "./jwt"; + +const isDpop= (trustedDomain: Domain[] | DomainDetails) : boolean => { + if (Array.isArray(trustedDomain)) { + return false; + } + return trustedDomain.demonstratingProofOfPossession ?? false; +} + +export const getDpopConfiguration = (trustedDomain: Domain[] | DomainDetails) => { + + if(!isDpop(trustedDomain)) { + return null; + } + + if (Array.isArray(trustedDomain)) { + return null; + } + + return trustedDomain.demonstratingProofOfPossessionConfiguration ?? defaultDemonstratingProofOfPossessionConfiguration; +} \ No newline at end of file diff --git a/packages/oidc-client-service-worker/src/jwt.ts b/packages/oidc-client-service-worker/src/jwt.ts new file mode 100644 index 000000000..ef7665f19 --- /dev/null +++ b/packages/oidc-client-service-worker/src/jwt.ts @@ -0,0 +1,267 @@ +// code base on https://coolaj86.com/articles/sign-jwt-webcrypto-vanilla-js/ + +// String (UCS-2) to Uint8Array +// +// because... JavaScript, Strings, and Buffers +// @ts-ignore +import {DemonstratingProofOfPossessionConfiguration} from "./types"; + +function strToUint8(str:string) { + return new TextEncoder().encode(str); +} + +// Binary String to URL-Safe Base64 +// +// btoa (Binary-to-Ascii) means "binary string" to base64 +// @ts-ignore +function binToUrlBase64(bin) { + return btoa(bin) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+/g, ''); +} + +// UTF-8 to Binary String +// +// Because JavaScript has a strange relationship with strings +// https://coolaj86.com/articles/base64-unicode-utf-8-javascript-and-you/ +// @ts-ignore +function utf8ToBinaryString(str) { + const escstr = encodeURIComponent(str); + // replaces any uri escape sequence, such as %0A, + // with binary escape, such as 0x0A + // @ts-ignore + return escstr.replace(/%([0-9A-F]{2})/g, function (match:string, p1) { + return String.fromCharCode(parseInt(p1, 16)); + }); +} + +// Uint8Array to URL Safe Base64 +// +// the shortest distant between two encodings... binary string +// @ts-ignore +export const uint8ToUrlBase64 =(uint8: Uint8Array) => { + let bin = ''; + // @ts-ignore + uint8.forEach(function(code) { + bin += String.fromCharCode(code); + }); + return binToUrlBase64(bin); +} + +// UCS-2 String to URL-Safe Base64 +// +// btoa doesn't work on UTF-8 strings +// @ts-ignore +function strToUrlBase64(str) { + return binToUrlBase64(utf8ToBinaryString(str)); +} + +export const defaultDemonstratingProofOfPossessionConfiguration: DemonstratingProofOfPossessionConfiguration ={ + importKeyAlgorithm: { + name: 'ECDSA', + namedCurve: 'P-256', + hash: {name: 'ES256'} + }, + signAlgorithm: {name: 'ECDSA', hash: {name: 'SHA-256'}}, + generateKeyAlgorithm: { + name: 'ECDSA', + namedCurve: 'P-256' + }, + digestAlgorithm: { name: 'SHA-256' }, + jwtHeaderAlgorithm : 'ES256' +} + + +// @ts-ignore +const sign = (w:any) => async (jwk, headers, claims, demonstratingProofOfPossessionConfiguration: DemonstratingProofOfPossessionConfiguration, jwtHeaderType= 'dpop+jwt') => { + // Make a shallow copy of the key + // (to set ext if it wasn't already set) + jwk = Object.assign({}, jwk); + + // The headers should probably be empty + headers.typ = jwtHeaderType; + headers.alg = demonstratingProofOfPossessionConfiguration.jwtHeaderAlgorithm; + switch (headers.alg) { + case 'ES256': //if (!headers.kid) { + // alternate: see thumbprint function below + headers.jwk = {kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y}; + //} + break; + case 'RS256': + headers.jwk = {kty: jwk.kty, n: jwk.n, e: jwk.e, kid: headers.kid}; + break; + default: + throw new Error('Unknown or not implemented JWS algorithm'); + } + + const jws = { + // @ts-ignore + // JWT "headers" really means JWS "protected headers" + protected: strToUrlBase64(JSON.stringify(headers)), + // @ts-ignore + // JWT "claims" are really a JSON-defined JWS "payload" + payload: strToUrlBase64(JSON.stringify(claims)) + }; + + // To import as EC (ECDSA, P-256, SHA-256, ES256) + const keyType = demonstratingProofOfPossessionConfiguration.importKeyAlgorithm; + + // To make re-exportable as JSON (or DER/PEM) + const exportable = true; + + // Import as a private key that isn't black-listed from signing + const privileges = ['sign']; + + // Actually do the import, which comes out as an abstract key type + // @ts-ignore + const privateKey = await w.crypto.subtle.importKey('jwk', jwk, keyType, exportable, privileges); + // Convert UTF-8 to Uint8Array ArrayBuffer + // @ts-ignore + const data = strToUint8(`${jws.protected}.${jws.payload}`); + + // The signature and hash should match the bit-entropy of the key + // https://tools.ietf.org/html/rfc7518#section-3 + const signatureType = demonstratingProofOfPossessionConfiguration.signAlgorithm; + + const signature = await w.crypto.subtle.sign(signatureType, privateKey, data); + // returns an ArrayBuffer containing a JOSE (not X509) signature, + // which must be converted to Uint8 to be useful + // @ts-ignore + jws.signature = uint8ToUrlBase64(new Uint8Array(signature)); + // JWT is just a "compressed", "protected" JWS + // @ts-ignore + return `${jws.protected}.${jws.payload}.${jws.signature}`; +}; + +export var JWT = {sign}; + + +// @ts-ignore +const generate = (w:any) => async (generateKeyAlgorithm: RsaHashedKeyGenParams | EcKeyGenParams) => { + const keyType = generateKeyAlgorithm; + const exportable = true; + const privileges = ['sign', 'verify']; + // @ts-ignore + const key = await w.crypto.subtle.generateKey(keyType, exportable, privileges); + // returns an abstract and opaque WebCrypto object, + // which in most cases you'll want to export as JSON to be able to save + return await w.crypto.subtle.exportKey('jwk', key.privateKey); +}; + +// Create a Public Key from a Private Key +// +// chops off the private parts +// @ts-ignore +const neuter = jwk => { + const copy = Object.assign({}, jwk); + delete copy.d; + copy.key_ops = ['verify']; + return copy; +}; + +const EC = { + generate, + neuter +}; +// @ts-ignore +const thumbprint = (w:any) => async (jwk, digestAlgorithm: AlgorithmIdentifier) => { + let sortedPub; + // lexigraphically sorted, no spaces + switch (jwk.kty) { + case 'EC': + sortedPub = '{"crv":"CRV","kty":"EC","x":"X","y":"Y"}' + .replace('CRV', jwk.crv) + .replace('X', jwk.x) + .replace('Y', jwk.y); + break; + case 'RSA': + sortedPub = '{"e":"E","kty":"RSA","n":"N"}' + .replace('E', jwk.e) + .replace('N', jwk.n); + break; + default: + throw new Error('Unknown or not implemented JWK type'); + } + // The hash should match the size of the key, + // but we're only dealing with P-256 + const hash = await w.crypto.subtle.digest(digestAlgorithm, strToUint8(sortedPub)); + return uint8ToUrlBase64(new Uint8Array(hash)); +} + +export var JWK = {thumbprint}; + +export const generateJwkAsync = (w:any) => async (generateKeyAlgorithm: RsaHashedKeyGenParams | EcKeyGenParams) => { + // @ts-ignore + const jwk = await EC.generate(w)(generateKeyAlgorithm); + // console.info('Private Key:', JSON.stringify(jwk)); + // @ts-ignore + // console.info('Public Key:', JSON.stringify(EC.neuter(jwk))); + return jwk; +} + +export const generateJwtDemonstratingProofOfPossessionAsync = (w:any) => (demonstratingProofOfPossessionConfiguration: DemonstratingProofOfPossessionConfiguration) => async (jwk:any, method = 'POST', url: string, extrasClaims={}) => { + + const claims = { + // https://www.rfc-editor.org/rfc/rfc9449.html#name-concept + jti: btoa(guid()), + htm: method, + htu: url, + iat: Math.round(Date.now() / 1000), + ...extrasClaims, + }; + // @ts-ignore + const kid = await JWK.thumbprint(w)(jwk, demonstratingProofOfPossessionConfiguration.digestAlgorithm); + // @ts-ignore + const jwt = await JWT.sign(w)(jwk, { kid: kid }, claims, demonstratingProofOfPossessionConfiguration) + // console.info('JWT:', jwt); + return jwt; +} + +const guid = () => { + // RFC4122: The version 4 UUID is meant for generating UUIDs from truly-random or + // pseudo-random numbers. + // The algorithm is as follows: + // Set the two most significant bits (bits 6 and 7) of the + // clock_seq_hi_and_reserved to zero and one, respectively. + // Set the four most significant bits (bits 12 through 15) of the + // time_hi_and_version field to the 4-bit version number from + // Section 4.1.3. Version4 + // Set all the other bits to randomly (or pseudo-randomly) chosen + // values. + // UUID = time-low "-" time-mid "-"time-high-and-version "-"clock-seq-reserved and low(2hexOctet)"-" node + // time-low = 4hexOctet + // time-mid = 2hexOctet + // time-high-and-version = 2hexOctet + // clock-seq-and-reserved = hexOctet: + // clock-seq-low = hexOctet + // node = 6hexOctet + // Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + // y could be 1000, 1001, 1010, 1011 since most significant two bits needs to be 10 + // y values are 8, 9, A, B + const guidHolder = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; + const hex = '0123456789abcdef'; + let r = 0; + let guidResponse = ""; + for (let i = 0; i < 36; i++) { + if (guidHolder[i] !== '-' && guidHolder[i] !== '4') { + // each x and y needs to be random + r = Math.random() * 16 | 0; + } + + if (guidHolder[i] === 'x') { + guidResponse += hex[r]; + } else if (guidHolder[i] === 'y') { + // clock-seq-and-reserved first hex is filtered and remaining hex values are random + r &= 0x3; // bit and with 0011 to set pos 2 to zero ?0?? + r |= 0x8; // set pos 3 to 1 as 1??? + guidResponse += hex[r]; + } else { + guidResponse += guidHolder[i]; + } + } + + return guidResponse; +}; + + diff --git a/packages/oidc-client-service-worker/src/types.ts b/packages/oidc-client-service-worker/src/types.ts index d669b0b96..ef3244671 100644 --- a/packages/oidc-client-service-worker/src/types.ts +++ b/packages/oidc-client-service-worker/src/types.ts @@ -5,6 +5,16 @@ export type DomainDetails = { showAccessToken: boolean; convertAllRequestsToCorsExceptNavigate?: boolean, setAccessTokenToNavigateRequests?: boolean, + demonstratingProofOfPossession?:boolean; + demonstratingProofOfPossessionConfiguration?: DemonstratingProofOfPossessionConfiguration; +} + +export interface DemonstratingProofOfPossessionConfiguration { + generateKeyAlgorithm: RsaHashedKeyGenParams | EcKeyGenParams, + digestAlgorithm: AlgorithmIdentifier, + importKeyAlgorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, + signAlgorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams, + jwtHeaderAlgorithm: string } export type Domain = string | RegExp; @@ -23,6 +33,7 @@ export type OidcServerConfiguration = { export type OidcConfiguration = { token_renew_mode: string; + demonstrating_proof_of_possession: boolean; } // Uncertain why the Headers interface in lib.webworker.d.ts does not have a keys() function, so extending @@ -57,6 +68,7 @@ export type Nonce = { } | null; export type OidcConfig = { + demonstratingProofOfPossessionConfiguration: DemonstratingProofOfPossessionConfiguration | null; configurationName: string; tokens: Tokens | null; status: Status; diff --git a/packages/oidc-client-service-worker/src/utils/__tests__/domains.spec.ts b/packages/oidc-client-service-worker/src/utils/__tests__/domains.spec.ts index 3ec9cc4bd..f2ab1e8fe 100644 --- a/packages/oidc-client-service-worker/src/utils/__tests__/domains.spec.ts +++ b/packages/oidc-client-service-worker/src/utils/__tests__/domains.spec.ts @@ -53,6 +53,7 @@ describe('domains', () => { setAccessTokenToNavigateRequests: true, demonstratingProofOfPossessionNonce: null, demonstratingProofOfPossessionJwkJson: null, + demonstratingProofOfPossessionConfiguration: null, }, }; }); diff --git a/packages/oidc-client-service-worker/src/utils/__tests__/testHelper.ts b/packages/oidc-client-service-worker/src/utils/__tests__/testHelper.ts index 39ce16af3..6e30e61e2 100644 --- a/packages/oidc-client-service-worker/src/utils/__tests__/testHelper.ts +++ b/packages/oidc-client-service-worker/src/utils/__tests__/testHelper.ts @@ -96,6 +96,7 @@ class TokenBuilder { class OidcConfigurationBuilder { private oidcConfiguration: OidcConfiguration = { token_renew_mode: 'offline', + demonstrating_proof_of_possession: false, }; public withTokenRenewMode( @@ -127,6 +128,7 @@ class OidcConfigBuilder { setAccessTokenToNavigateRequests: true, demonstratingProofOfPossessionNonce: null, demonstratingProofOfPossessionJwkJson: null, + demonstratingProofOfPossessionConfiguration: null, }; public withTestingDefault(): OidcConfigBuilder { diff --git a/packages/oidc-client-service-worker/src/utils/__tests__/tokens.spec.ts b/packages/oidc-client-service-worker/src/utils/__tests__/tokens.spec.ts index b35b6ba6d..72bf460a9 100644 --- a/packages/oidc-client-service-worker/src/utils/__tests__/tokens.spec.ts +++ b/packages/oidc-client-service-worker/src/utils/__tests__/tokens.spec.ts @@ -125,7 +125,7 @@ describe('tokens', () => { // @ts-ignore delete token.idTokenPayload; const oidcConfiguration = new OidcConfigBuilder() - .withOidcConfiguration({token_renew_mode: "access_token_invalid"}) + .withOidcConfiguration({token_renew_mode: "access_token_invalid", demonstrating_proof_of_possession: false}) .withOidcServerConfiguration({issuer: "", authorizationEndpoint:"", revocationEndpoint:"", diff --git a/packages/oidc-client-service-worker/src/utils/domains.ts b/packages/oidc-client-service-worker/src/utils/domains.ts index d811b2ea3..80533dc8c 100644 --- a/packages/oidc-client-service-worker/src/utils/domains.ts +++ b/packages/oidc-client-service-worker/src/utils/domains.ts @@ -36,6 +36,8 @@ export const getDomains = ( return trustedDomain[`${type}Domains`] ?? trustedDomain.domains ?? []; }; + + export const getCurrentDatabaseDomain = ( database: Database, url: string, diff --git a/packages/oidc-client-service-worker/src/utils/tokens.ts b/packages/oidc-client-service-worker/src/utils/tokens.ts index e21933e55..67c7296c5 100644 --- a/packages/oidc-client-service-worker/src/utils/tokens.ts +++ b/packages/oidc-client-service-worker/src/utils/tokens.ts @@ -1,5 +1,5 @@ /* eslint-disable simple-import-sort/exports */ -import { TOKEN, TokenRenewMode } from '../constants'; +import {TOKEN, TokenRenewMode} from '../constants'; import { AccessTokenPayload, IdTokenPayload, @@ -8,7 +8,7 @@ import { OidcServerConfiguration, Tokens } from '../types'; -import { countLetter } from './strings'; +import {countLetter} from './strings'; export const parseJwt = (payload: string) => { return JSON.parse( @@ -221,16 +221,27 @@ function _hideTokens(tokens: Tokens, currentDatabaseElement: OidcConfig, configu return secureTokens; } +const demonstratingProofOfPossessionNonceResponseHeader = "DPoP-Nonce"; function hideTokens(currentDatabaseElement: OidcConfig) { const configurationName = currentDatabaseElement.configurationName; return (response: Response) => { if (response.status !== 200) { return response; } + const newHeaders = new Headers(response.headers); + if( response.headers.has(demonstratingProofOfPossessionNonceResponseHeader)){ + currentDatabaseElement.demonstratingProofOfPossessionNonce = response.headers.get(demonstratingProofOfPossessionNonceResponseHeader); + newHeaders.delete(demonstratingProofOfPossessionNonceResponseHeader); + } + return response.json().then((tokens: Tokens) => { const secureTokens = _hideTokens(tokens, currentDatabaseElement, configurationName); const body = JSON.stringify(secureTokens); - return new Response(body, response); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders + }); }); }; } diff --git a/packages/oidc-client/README.md b/packages/oidc-client/README.md index 2cc14490b..be012d1a7 100644 --- a/packages/oidc-client/README.md +++ b/packages/oidc-client/README.md @@ -94,10 +94,32 @@ const trustedDomains = { trustedDomains.config_show_access_token = { oidcDomains :["https://demo.duendesoftware.com"], accessTokenDomains : ["https://www.myapi.com/users"], - showAccessToken: true, + showAccessToken: false, // convertAllRequestsToCorsExceptNavigate: false, // default value is false // setAccessTokenToNavigateRequests: true, // default value is true }; + +// DPoP (Demonstrating Proof of Possession) will be activated for the following domains +trustedDomains.config_with_dpop = { + domains: ["https://demo.duendesoftware.com"], + demonstratingProofOfPossession: true + // Optional, more details bellow + /*demonstratingProofOfPossessionConfiguration: { + importKeyAlgorithm: { + name: 'ECDSA', + namedCurve: 'P-256', + hash: {name: 'ES256'} + }, + signAlgorithm: {name: 'ECDSA', hash: {name: 'SHA-256'}}, + generateKeyAlgorithm: { + name: 'ECDSA', + namedCurve: 'P-256' + }, + digestAlgorithm: { name: 'SHA-256' }, + jwtHeaderAlgorithm : 'ES256' + }*/ +}; + ``` The code of the demo : @@ -113,7 +135,7 @@ export const configuration = { authority: 'https://demo.duendesoftware.com', service_worker_relative_url: '/OidcServiceWorker.js', // just comment that line to disable service worker mode service_worker_only: false, - demonstrating_proof_of_possession: false, // demonstrating proof of possession will work only if access_token is accessible from the client (This is because WebCrypto API is not available inside a Service Worker) + demonstrating_proof_of_possession: false, }; const href = window.location.href; diff --git a/packages/oidc-client/src/jwt.ts b/packages/oidc-client/src/jwt.ts index f65ec7b7e..4aadb41fe 100644 --- a/packages/oidc-client/src/jwt.ts +++ b/packages/oidc-client/src/jwt.ts @@ -73,7 +73,7 @@ export const defaultDemonstratingProofOfPossessionConfiguration: DemonstratingPr // @ts-ignore -const sign = async (jwk, headers, claims, demonstratingProofOfPossessionConfiguration: DemonstratingProofOfPossessionConfiguration, jwtHeaderType= 'dpop+jwt') => { +const sign = (w:any) => async (jwk, headers, claims, demonstratingProofOfPossessionConfiguration: DemonstratingProofOfPossessionConfiguration, jwtHeaderType= 'dpop+jwt') => { // Make a shallow copy of the key // (to set ext if it wasn't already set) jwk = Object.assign({}, jwk); @@ -114,7 +114,7 @@ const sign = async (jwk, headers, claims, demonstratingProofOfPossessionConfigur // Actually do the import, which comes out as an abstract key type // @ts-ignore - const privateKey = await window.crypto.subtle.importKey('jwk', jwk, keyType, exportable, privileges); + const privateKey = await w.crypto.subtle.importKey('jwk', jwk, keyType, exportable, privileges); // Convert UTF-8 to Uint8Array ArrayBuffer // @ts-ignore const data = strToUint8(`${jws.protected}.${jws.payload}`); @@ -123,7 +123,7 @@ const sign = async (jwk, headers, claims, demonstratingProofOfPossessionConfigur // https://tools.ietf.org/html/rfc7518#section-3 const signatureType = demonstratingProofOfPossessionConfiguration.signAlgorithm; - const signature = await window.crypto.subtle.sign(signatureType, privateKey, data); + const signature = await w.crypto.subtle.sign(signatureType, privateKey, data); // returns an ArrayBuffer containing a JOSE (not X509) signature, // which must be converted to Uint8 to be useful // @ts-ignore @@ -137,15 +137,15 @@ export var JWT = {sign}; // @ts-ignore -const generate = async (generateKeyAlgorithm: RsaHashedKeyGenParams | EcKeyGenParams) => { +const generate = (w:any) => async (generateKeyAlgorithm: RsaHashedKeyGenParams | EcKeyGenParams) => { const keyType = generateKeyAlgorithm; const exportable = true; const privileges = ['sign', 'verify']; // @ts-ignore - const key = await window.crypto.subtle.generateKey(keyType, exportable, privileges); + const key = await w.crypto.subtle.generateKey(keyType, exportable, privileges); // returns an abstract and opaque WebCrypto object, // which in most cases you'll want to export as JSON to be able to save - return await window.crypto.subtle.exportKey('jwk', key.privateKey); + return await w.crypto.subtle.exportKey('jwk', key.privateKey); }; // Create a Public Key from a Private Key @@ -164,7 +164,7 @@ const EC = { neuter }; // @ts-ignore -const thumbprint = async (jwk, digestAlgorithm: AlgorithmIdentifier) => { +const thumbprint = (w:any) => async (jwk, digestAlgorithm: AlgorithmIdentifier) => { let sortedPub; // lexigraphically sorted, no spaces switch (jwk.kty) { @@ -184,22 +184,22 @@ const thumbprint = async (jwk, digestAlgorithm: AlgorithmIdentifier) => { } // The hash should match the size of the key, // but we're only dealing with P-256 - const hash = await window.crypto.subtle.digest(digestAlgorithm, strToUint8(sortedPub)); + const hash = await w.crypto.subtle.digest(digestAlgorithm, strToUint8(sortedPub)); return uint8ToUrlBase64(new Uint8Array(hash)); } export var JWK = {thumbprint}; -export const generateJwkAsync = async (generateKeyAlgorithm: RsaHashedKeyGenParams | EcKeyGenParams) => { +export const generateJwkAsync = (w:any) => async (generateKeyAlgorithm: RsaHashedKeyGenParams | EcKeyGenParams) => { // @ts-ignore - const jwk = await EC.generate(generateKeyAlgorithm); + const jwk = await EC.generate(w)(generateKeyAlgorithm); // console.info('Private Key:', JSON.stringify(jwk)); // @ts-ignore // console.info('Public Key:', JSON.stringify(EC.neuter(jwk))); return jwk; } -export const generateJwtDemonstratingProofOfPossessionAsync = (demonstratingProofOfPossessionConfiguration: DemonstratingProofOfPossessionConfiguration) => async (jwk, method = 'POST', url: string, extrasClaims={}) => { +export const generateJwtDemonstratingProofOfPossessionAsync = (w:any) => (demonstratingProofOfPossessionConfiguration: DemonstratingProofOfPossessionConfiguration) => async (jwk, method = 'POST', url: string, extrasClaims={}) => { const claims = { // https://www.rfc-editor.org/rfc/rfc9449.html#name-concept @@ -210,9 +210,9 @@ export const generateJwtDemonstratingProofOfPossessionAsync = (demonstratingProo ...extrasClaims, }; // @ts-ignore - const kid = await JWK.thumbprint(jwk, demonstratingProofOfPossessionConfiguration.digestAlgorithm); + const kid = await JWK.thumbprint(w)(jwk, demonstratingProofOfPossessionConfiguration.digestAlgorithm); // @ts-ignore - const jwt = await JWT.sign(jwk, { kid: kid }, claims, demonstratingProofOfPossessionConfiguration) + const jwt = await JWT.sign(w)(jwk, { kid: kid }, claims, demonstratingProofOfPossessionConfiguration) // console.info('JWT:', jwt); return jwt; } diff --git a/packages/oidc-client/src/login.ts b/packages/oidc-client/src/login.ts index 473c14055..7f37919bb 100644 --- a/packages/oidc-client/src/login.ts +++ b/packages/oidc-client/src/login.ts @@ -149,14 +149,14 @@ export const loginCallbackAsync = (oidc:Oidc) => async (isSilentSignin = false) const url = oidcServerConfiguration.tokenEndpoint; const headersExtras = {}; if(configuration.demonstrating_proof_of_possession) { - const jwk = await generateJwkAsync(configuration.demonstrating_proof_of_possession_configuration.generateKeyAlgorithm); if (serviceWorker) { - await serviceWorker.setDemonstratingProofOfPossessionJwkAsync(jwk); + headersExtras['DPoP'] = `DPOP_SECURED_BY_OIDC_SERVICE_WORKER_${oidc.configurationName}`; } else { + const jwk = await generateJwkAsync(window)(configuration.demonstrating_proof_of_possession_configuration.generateKeyAlgorithm); const session = initSession(oidc.configurationName, configuration.storage); await session.setDemonstratingProofOfPossessionJwkAsync(jwk); + headersExtras['DPoP'] = await generateJwtDemonstratingProofOfPossessionAsync(window)(configuration.demonstrating_proof_of_possession_configuration)(jwk, 'POST', url); } - headersExtras['DPoP'] = await generateJwtDemonstratingProofOfPossessionAsync(configuration.demonstrating_proof_of_possession_configuration)(jwk, 'POST', url); } const tokenResponse = await performFirstTokenRequestAsync(storage)(url, diff --git a/packages/oidc-client/src/oidc.ts b/packages/oidc-client/src/oidc.ts index afcc232bf..f2eff47fd 100644 --- a/packages/oidc-client/src/oidc.ts +++ b/packages/oidc-client/src/oidc.ts @@ -300,21 +300,21 @@ Please checkout that you are using OIDC hook inside a = null; diff --git a/packages/react-oidc/README.md b/packages/react-oidc/README.md index 00c30601b..1fee9a95f 100644 --- a/packages/react-oidc/README.md +++ b/packages/react-oidc/README.md @@ -98,6 +98,28 @@ trustedDomains.config_show_access_token = { // setAccessTokenToNavigateRequests: true, // default value is true }; +// DPoP (Demonstrating Proof of Possession) will be activated for the following domains +trustedDomains.config_with_dpop = { + domains: ["https://demo.duendesoftware.com"], + demonstratingProofOfPossession: true + // Optional, more details bellow + /*demonstratingProofOfPossessionConfiguration: { + importKeyAlgorithm: { + name: 'ECDSA', + namedCurve: 'P-256', + hash: {name: 'ES256'} + }, + signAlgorithm: {name: 'ECDSA', hash: {name: 'SHA-256'}}, + generateKeyAlgorithm: { + name: 'ECDSA', + namedCurve: 'P-256' + }, + digestAlgorithm: { name: 'SHA-256' }, + jwtHeaderAlgorithm : 'ES256' + }*/ +}; + + ``` ## Run The Demo @@ -142,7 +164,7 @@ const configuration = { authority: "https://demo.duendesoftware.com", service_worker_relative_url: "/OidcServiceWorker.js", // just comment that line to disable service worker mode service_worker_only: false, - demonstrating_proof_of_possession: false, // demonstrating proof of possession will work only if access_token is accessible from the client (This is because WebCrypto API is not available inside a Service Worker) + demonstrating_proof_of_possession: false, }; const App = () => (