From 1e60f3bd6a67148fe3a3e65134f3484cf4a266c1 Mon Sep 17 00:00:00 2001 From: nmoskaleva Date: Tue, 25 Nov 2025 10:04:13 +0100 Subject: [PATCH 1/4] Add OIDC Visualizer --- package-lock.json | 164 ++++++++------ package.json | 1 + .../OidcVisualizer/OidcVisualizer.tsx | 200 ++++++++++++++++++ .../OidcVisualizer/components/StepCard.tsx | 98 +++++++++ .../components/steps/StepFour.tsx | 28 +++ .../components/steps/StepOne.tsx | 58 +++++ .../components/steps/StepThree.tsx | 76 +++++++ .../components/steps/StepTwo.tsx | 106 ++++++++++ src/components/OidcVisualizer/oidcConfig.tsx | 10 + src/components/OidcVisualizer/styles.tsx | 13 ++ src/components/OidcVisualizer/types.tsx | 8 + src/pages/verify/guides/oidc-visualizer.mdx | 44 ++++ 12 files changed, 742 insertions(+), 64 deletions(-) create mode 100644 src/components/OidcVisualizer/OidcVisualizer.tsx create mode 100644 src/components/OidcVisualizer/components/StepCard.tsx create mode 100644 src/components/OidcVisualizer/components/steps/StepFour.tsx create mode 100644 src/components/OidcVisualizer/components/steps/StepOne.tsx create mode 100644 src/components/OidcVisualizer/components/steps/StepThree.tsx create mode 100644 src/components/OidcVisualizer/components/steps/StepTwo.tsx create mode 100644 src/components/OidcVisualizer/oidcConfig.tsx create mode 100644 src/components/OidcVisualizer/styles.tsx create mode 100644 src/components/OidcVisualizer/types.tsx create mode 100644 src/pages/verify/guides/oidc-visualizer.mdx diff --git a/package-lock.json b/package-lock.json index 4e1fb7b5..52cb4c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "gatsby-plugin-sitemap": "^6.14.0", "graphiql": "^3.8.3", "graphql-tag": "^2.12.6", + "jose": "^6.1.0", "lodash": "^4.17.21", "react": "^18.0.0", "react-dom": "^18.0.0", @@ -2515,6 +2516,33 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, + "node_modules/@graphiql/react/node_modules/framer-motion": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz", + "integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==", + "license": "MIT", + "dependencies": { + "@motionone/dom": "10.12.0", + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "popmotion": "11.0.3", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": ">=16.8 || ^17.0.0 || ^18.0.0", + "react-dom": ">=16.8 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@graphiql/react/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@graphiql/toolkit": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.11.1.tgz", @@ -4303,6 +4331,16 @@ "ws": "*" } }, + "node_modules/@graphql-tools/prisma-loader/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@graphql-tools/prisma-loader/node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -4915,6 +4953,7 @@ "version": "10.18.0", "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.18.0.tgz", "integrity": "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw==", + "license": "MIT", "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", @@ -4925,12 +4964,14 @@ "node_modules/@motionone/animation/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/@motionone/dom": { "version": "10.12.0", "resolved": "https://registry.npmjs.org/@motionone/dom/-/dom-10.12.0.tgz", "integrity": "sha512-UdPTtLMAktHiqV0atOczNYyDd/d8Cf5fFsd1tua03PqTwwCe/6lwhLSQ8a7TbnQ5SN0gm44N1slBfj+ORIhrqw==", + "license": "MIT", "dependencies": { "@motionone/animation": "^10.12.0", "@motionone/generators": "^10.12.0", @@ -4943,12 +4984,14 @@ "node_modules/@motionone/dom/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/@motionone/easing": { "version": "10.18.0", "resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.18.0.tgz", "integrity": "sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg==", + "license": "MIT", "dependencies": { "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" @@ -4957,12 +5000,14 @@ "node_modules/@motionone/easing/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/@motionone/generators": { "version": "10.18.0", "resolved": "https://registry.npmjs.org/@motionone/generators/-/generators-10.18.0.tgz", "integrity": "sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg==", + "license": "MIT", "dependencies": { "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", @@ -4972,17 +5017,20 @@ "node_modules/@motionone/generators/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/@motionone/types": { "version": "10.17.1", "resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.17.1.tgz", - "integrity": "sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A==" + "integrity": "sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A==", + "license": "MIT" }, "node_modules/@motionone/utils": { "version": "10.18.0", "resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.18.0.tgz", "integrity": "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw==", + "license": "MIT", "dependencies": { "@motionone/types": "^10.17.1", "hey-listen": "^1.0.8", @@ -4992,7 +5040,8 @@ "node_modules/@motionone/utils/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/@n1ru4l/push-pull-async-iterable-iterator": { "version": "3.2.0", @@ -15116,35 +15165,11 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/framer-motion": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz", - "integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==", - "dependencies": { - "@motionone/dom": "10.12.0", - "framesync": "6.0.1", - "hey-listen": "^1.0.8", - "popmotion": "11.0.3", - "style-value-types": "5.0.0", - "tslib": "^2.1.0" - }, - "optionalDependencies": { - "@emotion/is-prop-valid": "^0.8.2" - }, - "peerDependencies": { - "react": ">=16.8 || ^17.0.0 || ^18.0.0", - "react-dom": ">=16.8 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/framer-motion/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, "node_modules/framesync": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz", "integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==", + "license": "MIT", "dependencies": { "tslib": "^2.1.0" } @@ -15152,7 +15177,8 @@ "node_modules/framesync/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/fresh": { "version": "0.5.2", @@ -19203,7 +19229,8 @@ "node_modules/hey-listen": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", - "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", + "license": "MIT" }, "node_modules/highlight.js": { "version": "10.7.3", @@ -20645,10 +20672,10 @@ } }, "node_modules/jose": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.8.0.tgz", - "integrity": "sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==", - "dev": true, + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -24927,6 +24954,7 @@ "version": "11.0.3", "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz", "integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==", + "license": "MIT", "dependencies": { "framesync": "6.0.1", "hey-listen": "^1.0.8", @@ -24937,7 +24965,8 @@ "node_modules/popmotion/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/possible-typed-array-names": { "version": "1.0.0", @@ -28614,6 +28643,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz", "integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==", + "license": "MIT", "dependencies": { "hey-listen": "^1.0.8", "tslib": "^2.1.0" @@ -28622,7 +28652,8 @@ "node_modules/style-value-types/node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/stylehacks": { "version": "5.1.0", @@ -32937,6 +32968,27 @@ "markdown-it": "^14.1.0", "react-compiler-runtime": "19.0.0-beta-37ed2a7-20241206", "set-value": "^4.1.0" + }, + "dependencies": { + "framer-motion": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz", + "integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==", + "requires": { + "@emotion/is-prop-valid": "^0.8.2", + "@motionone/dom": "10.12.0", + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "popmotion": "11.0.3", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + } + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } } }, "@graphiql/toolkit": { @@ -34292,6 +34344,12 @@ "dev": true, "requires": {} }, + "jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "dev": true + }, "js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -41718,27 +41776,6 @@ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==" }, - "framer-motion": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz", - "integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==", - "requires": { - "@emotion/is-prop-valid": "^0.8.2", - "@motionone/dom": "10.12.0", - "framesync": "6.0.1", - "hey-listen": "^1.0.8", - "popmotion": "11.0.3", - "style-value-types": "5.0.0", - "tslib": "^2.1.0" - }, - "dependencies": { - "tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - } - } - }, "framesync": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz", @@ -45510,10 +45547,9 @@ } }, "jose": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.8.0.tgz", - "integrity": "sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==", - "dev": true + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==" }, "js-tokens": { "version": "4.0.0", diff --git a/package.json b/package.json index 754e7ade..d2794dd2 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "gatsby-plugin-sitemap": "^6.14.0", "graphiql": "^3.8.3", "graphql-tag": "^2.12.6", + "jose": "^6.1.0", "lodash": "^4.17.21", "react": "^18.0.0", "react-dom": "^18.0.0", diff --git a/src/components/OidcVisualizer/OidcVisualizer.tsx b/src/components/OidcVisualizer/OidcVisualizer.tsx new file mode 100644 index 00000000..459f09cc --- /dev/null +++ b/src/components/OidcVisualizer/OidcVisualizer.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useLayoutEffect, useState, useRef } from 'react'; +import { jwtVerify, createRemoteJWKSet, JWTPayload } from 'jose'; +import oidcConfig from './oidcConfig'; +import StepOne from './components/steps/StepOne'; +import StepTwo from './components/steps/StepTwo'; +import StepThree from './components/steps/StepThree'; +import StepFour from './components/steps/StepFour'; +import { secondaryBtn } from './styles'; +import type { OidcTokenResponse } from './types'; + +const OidcVisualizer = () => { + const [authCode, setAuthCode] = useState(null); + const [tokenResponse, setTokenResponse] = useState(null); + const [decodedPayload, setDecodedPayload] = useState(null); + const [tokenRequest, setTokenRequest] = useState(null); + const [codeExchangeCompleted, setCodeExchangeCompleted] = useState(false); + const [step2Error, setStep2Error] = useState(null); + const [step3Error, setStep3Error] = useState(null); + + const authorizeUrl = `https://${oidcConfig.domain}/oauth2/authorize?response_type=code&client_id=${oidcConfig.clientId}&redirect_uri=${encodeURIComponent(oidcConfig.redirectUri)}&scope=${encodeURIComponent(oidcConfig.scope)}`; + + /* Define the current step for scrolling */ + const STEP = { + STEP_1: 1, + STEP_2: 2, + STEP_3: 3, + STEP_4: 4, + } as const; + + const step1Ref = useRef(null); + const step2Ref = useRef(null); + const step3Ref = useRef(null); + const step4Ref = useRef(null); + + const currentStep = (() => { + if (decodedPayload) return STEP.STEP_4; + if (tokenResponse && codeExchangeCompleted) return STEP.STEP_3; + if (authCode) return STEP.STEP_2; + return STEP.STEP_1; + })(); + + const stepRefs = { + [STEP.STEP_1]: step1Ref, + [STEP.STEP_2]: step2Ref, + [STEP.STEP_3]: step3Ref, + [STEP.STEP_4]: step4Ref, + }; + + /* Scroll to current step on step change */ + useLayoutEffect(() => { + const el = stepRefs[currentStep].current; + if (!el) return; + + const headerHeight = 56; + const elementTop = el.getBoundingClientRect().top; + const targetY = elementTop + window.scrollY - headerHeight; + + if (tokenResponse && !decodedPayload && currentStep !== 3) { + el.scrollIntoView({ behavior: 'smooth', block: 'end' }); + // When token is received, scroll to the bottom of step #2 to show "Next" button + } else { + // Otherwise, scroll to top + window.scrollTo({ + top: targetY, + behavior: 'smooth', + }); + } + }, [currentStep, tokenResponse]); + + /* Extract authorization code from URL on mount */ + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + + if (code) { + setAuthCode(code); + window.history.replaceState({}, document.title, window.location.pathname); // Remove authorization code from URL + } + }, []); + + /* Handle code for token exchange */ + const handleExchange = async () => { + if (!authCode) return; + + try { + const getToken = await fetch(`https://${oidcConfig.domain}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: oidcConfig.clientId, + client_secret: oidcConfig.clientSecret, + code: authCode, + redirect_uri: oidcConfig.redirectUri, + }), + }); + + const formattedRequest = [ + `POST /oauth2/token HTTP/1.1`, + `Host: ${oidcSettings.domain}`, + `Content-Type: application/x-www-form-urlencoded`, + ``, + new URLSearchParams(params).toString().replaceAll('&', '\n'), + ].join('\n'); + + setTokenRequest(formattedRequest); + + const data: OidcTokenResponse = await getToken.json(); + + if (data.error) throw new Error(data.error_description || data.error); + setTokenResponse(data); + } catch (err: any) { + setStep2Error(err.message); + } + }; + + /* Proceed to token verification step */ + const proceedToVerifyWT = () => { + setCodeExchangeCompleted(true); + }; + + /* Handle JWT validation */ + const handleVerify = async () => { + if (!tokenResponse?.id_token) return; + + try { + const JWKS = createRemoteJWKSet( + new URL(`https://${oidcConfig.domain}/.well-known/jwks.json`), + ); + const { payload } = await jwtVerify(tokenResponse.id_token, JWKS); + setDecodedPayload(payload); + } catch (err: any) { + setStep3Error('Token Verification Failed: ' + err.message); + } + }; + + const handleReset = () => { + setAuthCode(null); + setTokenResponse(null); + setDecodedPayload(null); + setCodeExchangeCompleted(false); + setStep2Error(null); + setStep3Error(null); + window.history.replaceState({}, document.title, window.location.pathname); + }; + + return ( +
+
+

OpenID Connect Visualizer

+
+ + {/* STEP 1: Authorization */} + { + window.location.href = authorizeUrl; + }} + /> + + {/* STEP 2: Code for Token Exchange */} + + + {/* STEP 3: Token Verification */} + + + {/* STEP 4: Result */} + {decodedPayload && } + + {/* Reset Button */} + {(decodedPayload || step2Error || step3Error) && ( +
+ +
+ )} +
+ ); +}; + +export default OidcVisualizer; diff --git a/src/components/OidcVisualizer/components/StepCard.tsx b/src/components/OidcVisualizer/components/StepCard.tsx new file mode 100644 index 00000000..b5bee8cc --- /dev/null +++ b/src/components/OidcVisualizer/components/StepCard.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import cx from 'classnames'; + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; +type JsonData = { [key: string]: JsonValue }; + +type StepCardProps = { + number?: number; + title?: string; + description?: string | React.ReactNode; + req?: string | JsonData | null; + res?: string | JsonData | null; + reqNote?: React.ReactNode; + action?: React.ReactNode; + isActive?: boolean; + isCompleted?: boolean; + error?: string | null; + responseId?: string; + cardRef?: React.RefObject; +}; + +const StepCard = ({ + number, + title, + description, + req, + reqNote, + res, + action, + isActive, + isCompleted, + error, + responseId, + cardRef, +}: StepCardProps) => { + const showStep = isActive || isCompleted ? 'block' : 'hidden'; + + return ( +
+
+
+ {isCompleted ? : number} +
+

{title}

+
+ +

{description}

+ + {/* Request Section */} + {req && ( +
+ Request +
+            {typeof req === 'string' ? req : JSON.stringify(req, null, 2)}
+          
+ + {reqNote && ( +
{reqNote}
+ )} +
+ )} + + {/* Response Section */} + {res && ( +
+ Response +
+            {typeof res === 'string' ? res : JSON.stringify(res, null, 2)}
+          
+
+ )} + + {action &&
{action}
} + + {error && ( +
+

An error occurred

+

{error}

+
+ )} +
+ ); +}; + +export default StepCard; diff --git a/src/components/OidcVisualizer/components/steps/StepFour.tsx b/src/components/OidcVisualizer/components/steps/StepFour.tsx new file mode 100644 index 00000000..21030bbb --- /dev/null +++ b/src/components/OidcVisualizer/components/steps/StepFour.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import StepCard from '../StepCard'; +import { JWTPayload } from 'jose'; + +type StepOneProps = { + stepRef: React.RefObject; + decodedPayload: JWTPayload | null; +}; + +export default function StepFour({ stepRef, decodedPayload }: StepOneProps) { + return ( + +

+ Decoded payload: +

+
{JSON.stringify(decodedPayload, null, 2)}
+ + } + isActive={!!decodedPayload} + isCompleted={!!decodedPayload} + /> + ); +} diff --git a/src/components/OidcVisualizer/components/steps/StepOne.tsx b/src/components/OidcVisualizer/components/steps/StepOne.tsx new file mode 100644 index 00000000..5cc8f866 --- /dev/null +++ b/src/components/OidcVisualizer/components/steps/StepOne.tsx @@ -0,0 +1,58 @@ +import React, { MouseEvent } from 'react'; +import StepCard from '../StepCard'; +import { primaryBtn } from '../../styles'; + +type StepOneProps = { + stepRef: React.RefObject; + authorizeUrl: string; + authCode?: string | null; + onLogin: (event: MouseEvent) => void; +}; + +export function formatUrl(url: string): string { + const [base, query] = url.split('?'); + if (!query) return base; + + const formattedParams = query + .split('&') + .map(param => ' ' + decodeURIComponent(param)) + .join('\n'); + + return `${base}?\n${formattedParams}`; +} + +export default function StepOne({ stepRef, authorizeUrl, authCode, onLogin }: StepOneProps) { + return ( + +

+ The client application creates an authentication request with the required parameters ( + response_type, redirect_uri, etc.) and redirects the user to + the Authorization Endpoint. Here, the user authenticates via one of the eIDs supported + by Idura Verify. +

+

+ Please use{' '} + + test user credentials + {' '} + to log in. +

+ + } + req={formatUrl(authorizeUrl)} + res={authCode ? { code: authCode } : null} + isActive={!authCode} + isCompleted={!!authCode} + action={ + + } + /> + ); +} diff --git a/src/components/OidcVisualizer/components/steps/StepThree.tsx b/src/components/OidcVisualizer/components/steps/StepThree.tsx new file mode 100644 index 00000000..fbdb0e2a --- /dev/null +++ b/src/components/OidcVisualizer/components/steps/StepThree.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { JWTPayload } from 'jose'; +import StepCard from '../StepCard'; +import { primaryBtn } from '../../styles'; +import type { OidcTokenResponse } from '../../types'; +import oidcConfig from '../../oidcConfig'; + +type StepThreeProps = { + stepRef: React.RefObject; + tokenResponse: OidcTokenResponse | null; + codeExchangeCompleted: boolean; + decodedPayload: JWTPayload | null; + onVerify: () => void; + step3Error?: string | null; +}; + +export default function StepThree({ + stepRef, + tokenResponse, + decodedPayload, + codeExchangeCompleted, + onVerify, + step3Error, +}: StepThreeProps) { + const jwksUrl = `https://${oidcConfig.domain}/.well-known/jwks`; + return ( + +

+ The final step is validating the{' '} + + ID Token + + . To confirm that the token originates from the expected issuer and has not been + tampered with, the client application must check its{' '} + + signature + {' '} + and{' '} + + claims + + . +

+ +

Your id_token is:

+ +
{tokenResponse.id_token}
+ +

+ The token is cryptographically signed by Idura Verify. To validate the signature, we + use the public key from one of the asymmetric key pairs published at{' '} + + {jwksUrl} + + . The correct key is selected based on the token’s Key ID (kid). +

+ + ) + } + isActive={!!tokenResponse && !decodedPayload && codeExchangeCompleted} + isCompleted={!!decodedPayload} + action={ + + } + error={step3Error} + /> + ); +} diff --git a/src/components/OidcVisualizer/components/steps/StepTwo.tsx b/src/components/OidcVisualizer/components/steps/StepTwo.tsx new file mode 100644 index 00000000..025489d6 --- /dev/null +++ b/src/components/OidcVisualizer/components/steps/StepTwo.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import StepCard from '../StepCard'; +import { primaryBtn } from '../../styles'; +import type { OidcTokenResponse } from '../../types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import cx from 'classnames'; + +type StepTwoProps = { + stepRef: React.RefObject; + authCode: string | null; + tokenResponse: OidcTokenResponse | null; + codeExchangeCompleted: boolean; + onCodeExchange: () => void; + proceedToVerifyWT: () => void; + step2Error?: string | null; + req: string | null; + pkJwtAuth: boolean; +}; + +export default function StepTwo({ + stepRef, + authCode, + tokenResponse, + codeExchangeCompleted, + onCodeExchange, + proceedToVerifyWT, + step2Error, + req, + pkJwtAuth, +}: StepTwoProps) { + return ( + + Your Authorization Code is:{' '} + + {authCode} + +

+ The code can now be exchanged for an Access Token and an ID Token. The client + initiates the exchange by sending a POST request with the code and the client + credentials to the{' '} + Token Endpoint at Idura + Verify. +

+ + The authorization code is only valid for a single use. Another token request with the + same code will trigger an{' '} + + Unknown Access Code + {' '} + error. + + + ) + } + req={authCode ? req : null} + reqNote={ + !pkJwtAuth ? ( + <> + Note: Including client credentials (client_id and{' '} + client_secret) in the payload is discouraged by the OAuth2 specification. + Server-side applications should instead send the credentials in the{' '} + Authorization: Basic HTTP header. + + ) : null + } + responseId="codeExchangeResponse" + res={tokenResponse} + isActive={!!authCode && !codeExchangeCompleted} + isCompleted={!!tokenResponse && codeExchangeCompleted} + action={ + !tokenResponse ? ( + + ) : ( +
+ + + +
+ ) + } + error={step2Error} + /> + ); +} diff --git a/src/components/OidcVisualizer/oidcConfig.tsx b/src/components/OidcVisualizer/oidcConfig.tsx new file mode 100644 index 00000000..370e73ae --- /dev/null +++ b/src/components/OidcVisualizer/oidcConfig.tsx @@ -0,0 +1,10 @@ +const oidcConfig = { + clientId: 'urn:oidc:visualizer', + clientSecret: '4AGzg0LRnJpoVzHV01/N9YfBDZkwcoePYca5QB93c88=', + domain: 'docs-samples-test.criipto.id', + redirectUri: `${typeof window !== 'undefined' ? window.location.origin : 'https://docs.idura.app'}/verify/guides/oidc-visualizer`, + scope: 'openid', + responseType: 'code', +}; + +export default oidcConfig; diff --git a/src/components/OidcVisualizer/styles.tsx b/src/components/OidcVisualizer/styles.tsx new file mode 100644 index 00000000..142ce03d --- /dev/null +++ b/src/components/OidcVisualizer/styles.tsx @@ -0,0 +1,13 @@ +export const primaryBtn = + 'bg-primary-600 hover:bg-primary-700 font-medium py-3 px-4 uppercase text-white focus:outline-none focus:shadow-outline text-sm'; + +export const secondaryBtn = + 'border-2 border-primary-600 bg-white text-primary-600 hover:bg-primary-600/10 font-medium py-3 px-4 uppercase text-white focus:outline-none focus:shadow-outline text-sm'; + +export const linkStyles = 'text-primary-700 underline hover:text-primary-900'; + +export const inputStyles = + 'flex-1 border border-gray-300 px-2 py-1 text-gray-900 transition-colors focus:border-primary-600 focus:ring-0 focus:ring-primary-600 focus:outline-none'; + +export const disabledInputStyles = + 'flex-1 border border-gray-300 bg-gray-100 text-gray-700 px-2 py-1'; diff --git a/src/components/OidcVisualizer/types.tsx b/src/components/OidcVisualizer/types.tsx new file mode 100644 index 00000000..ff8489eb --- /dev/null +++ b/src/components/OidcVisualizer/types.tsx @@ -0,0 +1,8 @@ +export type OidcTokenResponse = { + token_type: 'Bearer'; + expires_in: number; + id_token: string; + access_token: string; + error?: string; + error_description?: string; +}; diff --git a/src/pages/verify/guides/oidc-visualizer.mdx b/src/pages/verify/guides/oidc-visualizer.mdx new file mode 100644 index 00000000..18b0565b --- /dev/null +++ b/src/pages/verify/guides/oidc-visualizer.mdx @@ -0,0 +1,44 @@ +--- +product: verify +category: Guides & Tools +sort: 0 +title: OpenID Connect Visualizer +subtitle: An interactive walkthrough of the Authorization Code Flow sequence. +--- + +import OidcVisualizer from '../../../components/OidcVisualizer/OidcVisualizer'; + +OpenID Connect Visualizer is built to help developers understand the OpenID Connect protocol. +It demonstrates how a standard [Authorization Code Flow](/verify/getting-started/oidc-intro/#authenticate-with-back-channel-authorization-code-flow) unfolds between a [client application](/verify/reference/glossary/#application-client-application) and Idura Verify acting as the [Authorization Server](/verify/reference/glossary/#authorization-server). +While Idura Verify handles most of the protocol's complexity, the client application is responsible for initiating the flow, handling responses according to the [OpenID Connect Core specification](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth), and validating the [JWT token](/verify/reference/glossary/#json-web-token-jwt) it receives from Idura Verify. + + + +### Authorization Code Flow Steps Overview + +1. **Authentication Request** + +The flow begins with a client application redirecting the user to Idura Verify for authentication. +Idura Verify authenticates the user and redirects them back to the client application with a temporary Authorization Code. + +2. **Exchange Authorization Code for Tokens** + +The Authorization Code alone does not contain any information about the user. +The client application must [exchange the code for tokens](/verify/reference/glossary/#code-exchange) at the Authorization Server. + +3. **Verify ID Token** + +Before the client application can trust the user's identity data, it must cryptographically validate the ID Token received from Idura Verify. + +4. **Get Token Verification Results** + +After validation, the client extracts user [claims](/verify/reference/glossary/#claims) from the ID Token payload. +This data can be used to establish a local session and log the user in. + + + +### Interactive Demo + +Click through the steps to send requests from the client application and observe requests and responses exchanged during the process. + + From a856b2b921f518f307138342f489c776873db74f Mon Sep 17 00:00:00 2001 From: nmoskaleva Date: Fri, 19 Dec 2025 11:44:37 +0100 Subject: [PATCH 2/4] OIDC Visualizer: add settings modal This allows developers run the OIDC flow using their own application credentials. --- package-lock.json | 47 +++-- package.json | 1 + .../OidcVisualizer/OidcVisualizer.tsx | 85 ++++++-- .../components/OidcSettingsModal.tsx | 190 ++++++++++++++++++ .../OidcVisualizer/hooks/useLocalStorage.tsx | 16 ++ src/components/OidcVisualizer/oidcConfig.tsx | 2 + src/components/OidcVisualizer/types.tsx | 8 + 7 files changed, 322 insertions(+), 27 deletions(-) create mode 100644 src/components/OidcVisualizer/components/OidcSettingsModal.tsx create mode 100644 src/components/OidcVisualizer/hooks/useLocalStorage.tsx diff --git a/package-lock.json b/package-lock.json index 52cb4c57..2c821d2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "@graphql-codegen/typescript-operations": "^4.0.0", "@tailwindcss/typography": "^0.5.0", "@types/node": "^24.7.1", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.3", "autoprefixer": "^10.4.0", "gatsby-adapter-netlify": "^1.2.0", @@ -8173,12 +8174,22 @@ } }, "node_modules/@types/react": { - "version": "19.0.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", - "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -12364,9 +12375,10 @@ } }, "node_modules/csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" }, "node_modules/currently-unhandled": { "version": "0.4.1", @@ -36823,13 +36835,20 @@ } }, "@types/react": { - "version": "19.0.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", - "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "requires": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, + "@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "requires": {} + }, "@types/react-syntax-highlighter": { "version": "15.5.3", "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.3.tgz", @@ -39823,9 +39842,9 @@ } }, "csstype": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.10.tgz", - "integrity": "sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==" + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, "currently-unhandled": { "version": "0.4.1", diff --git a/package.json b/package.json index d2794dd2..367a6214 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@graphql-codegen/typescript-operations": "^4.0.0", "@tailwindcss/typography": "^0.5.0", "@types/node": "^24.7.1", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.3", "autoprefixer": "^10.4.0", "gatsby-adapter-netlify": "^1.2.0", diff --git a/src/components/OidcVisualizer/OidcVisualizer.tsx b/src/components/OidcVisualizer/OidcVisualizer.tsx index 459f09cc..de74f313 100644 --- a/src/components/OidcVisualizer/OidcVisualizer.tsx +++ b/src/components/OidcVisualizer/OidcVisualizer.tsx @@ -1,12 +1,15 @@ import React, { useEffect, useLayoutEffect, useState, useRef } from 'react'; import { jwtVerify, createRemoteJWKSet, JWTPayload } from 'jose'; +import useLocalStorage from './hooks/useLocalStorage'; +import OidcSettingsModal from './components/OidcSettingsModal'; import oidcConfig from './oidcConfig'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import StepOne from './components/steps/StepOne'; import StepTwo from './components/steps/StepTwo'; import StepThree from './components/steps/StepThree'; import StepFour from './components/steps/StepFour'; import { secondaryBtn } from './styles'; -import type { OidcTokenResponse } from './types'; +import type { OidcTokenResponse, OidcSettings } from './types'; const OidcVisualizer = () => { const [authCode, setAuthCode] = useState(null); @@ -16,8 +19,27 @@ const OidcVisualizer = () => { const [codeExchangeCompleted, setCodeExchangeCompleted] = useState(false); const [step2Error, setStep2Error] = useState(null); const [step3Error, setStep3Error] = useState(null); + const [showSettings, setShowSettings] = useState(false); + const [oidcSettings, setOidcSettings] = useLocalStorage( + 'oidc-settings', + oidcConfig, + ); + + const authorizeUrl = `https://${oidcSettings.domain}/oauth2/authorize?response_type=code&client_id=${oidcSettings.clientId}&redirect_uri=${encodeURIComponent(oidcConfig.redirectUri)}&scope=${encodeURIComponent(oidcSettings.scope)}${ + oidcSettings.acrValues?.length + ? `&acr_values=${encodeURIComponent(oidcSettings.acrValues.join(' '))}` + : '' + }`; - const authorizeUrl = `https://${oidcConfig.domain}/oauth2/authorize?response_type=code&client_id=${oidcConfig.clientId}&redirect_uri=${encodeURIComponent(oidcConfig.redirectUri)}&scope=${encodeURIComponent(oidcConfig.scope)}`; + /* Close settings modal with Escape key */ + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === 'Escape') setShowSettings(false); + }; + + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, []); /* Define the current step for scrolling */ const STEP = { @@ -83,15 +105,19 @@ const OidcVisualizer = () => { if (!authCode) return; try { - const getToken = await fetch(`https://${oidcConfig.domain}/oauth2/token`, { + const baseParams: Record = { + grant_type: 'authorization_code', + code: authCode, + redirect_uri: oidcConfig.redirectUri, + }; + + const getToken = await fetch(`https://${oidcSettings.domain}/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ - grant_type: 'authorization_code', - client_id: oidcConfig.clientId, - client_secret: oidcConfig.clientSecret, - code: authCode, - redirect_uri: oidcConfig.redirectUri, + ...baseParams, + client_id: oidcSettings.clientId, + client_secret: oidcSettings.clientSecret, }), }); @@ -100,7 +126,7 @@ const OidcVisualizer = () => { `Host: ${oidcSettings.domain}`, `Content-Type: application/x-www-form-urlencoded`, ``, - new URLSearchParams(params).toString().replaceAll('&', '\n'), + new URLSearchParams(baseParams).toString().replaceAll('&', '\n'), ].join('\n'); setTokenRequest(formattedRequest); @@ -125,7 +151,7 @@ const OidcVisualizer = () => { try { const JWKS = createRemoteJWKSet( - new URL(`https://${oidcConfig.domain}/.well-known/jwks.json`), + new URL(`https://${oidcSettings.domain}/.well-known/jwks.json`), ); const { payload } = await jwtVerify(tokenResponse.id_token, JWKS); setDecodedPayload(payload); @@ -144,10 +170,28 @@ const OidcVisualizer = () => { window.history.replaceState({}, document.title, window.location.pathname); }; + /* Updating OIDC settings */ + const handleUpdateSettings = (newSettings: OidcSettings) => { + setOidcSettings(prev => ({ + ...prev, + ...newSettings, + })); + handleReset(); + setShowSettings(false); + }; + return ( -
-
-

OpenID Connect Visualizer

+
+
+
{/* STEP 1: Authorization */} @@ -193,6 +237,21 @@ const OidcVisualizer = () => {
)} + + {/* OIDC Settings Modal */} + {showSettings && ( + setShowSettings(false)} + domain={oidcSettings.domain} + clientId={oidcSettings.clientId} + clientSecret={oidcSettings.clientSecret} + scope={oidcSettings.scope} + redirectUri={oidcConfig.redirectUri} + onSave={handleUpdateSettings} + acrValues={oidcSettings.acrValues} + /> + )}
); }; diff --git a/src/components/OidcVisualizer/components/OidcSettingsModal.tsx b/src/components/OidcVisualizer/components/OidcSettingsModal.tsx new file mode 100644 index 00000000..413ac9eb --- /dev/null +++ b/src/components/OidcVisualizer/components/OidcSettingsModal.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { primaryBtn, secondaryBtn, linkStyles, inputStyles, disabledInputStyles } from '../styles'; +import { PROVIDERS } from '../../../utils/auth-methods'; +import oidcConfig from '../oidcConfig'; + +type ModalProps = { + open: boolean; + onClose: () => void; + onSave: (newConfig: { + domain: string; + clientId: string; + clientSecret: string; + scope: string; + acrValues?: string[]; + }) => void; + domain: string; + clientId: string; + clientSecret: string; + scope: string; + redirectUri: string; + acrValues?: string[]; +}; + +export default function Modal({ + open, + onClose, + onSave, + domain, + clientId, + clientSecret, + scope, + acrValues, +}: ModalProps) { + const [settings, setSettings] = useState({ + domain, + clientId, + clientSecret, + scope, + acrValues: acrValues || [], + }); + + const toggleAcrValue = (value: string) => { + setSettings(prev => { + const current = prev.acrValues; + const next = current.includes(value) ? current.filter(v => v !== value) : [...current, value]; + return { ...prev, acrValues: next }; + }); + }; + + return createPortal( +
+
e.stopPropagation()} + > + + +

Client Configuration

+ +

+ This visualizer is configured to use Idura’s default application settings. You can update + them to test the OpenID Connect flow with your own Idura application instead. If you do, + make sure to add{' '} + + https://docs.idura.app/verify/guides/oidc-visualizer + {' '} + as a redirect URI in your application settings in the{' '} + + Idura dashboard + + . +

+ +
+
+ + setSettings({ ...settings, domain: e.target.value })} + /> +
+ +
+ + setSettings({ ...settings, clientId: e.target.value })} + /> +
+ +
+ + setSettings({ ...settings, clientSecret: e.target.value })} + /> +
+ +
+ + setSettings({ ...settings, scope: e.target.value })} + /> +
+ +
+ + +
+ +
+ + +
+ {PROVIDERS.map(provider => ( +
+

{provider.title}

+ +
+ {provider.authMethods.map(authMethod => ( + + ))} +
+
+ ))} +
+
+
+ +
+ + + +
+
+
, + document.body, + ); +} diff --git a/src/components/OidcVisualizer/hooks/useLocalStorage.tsx b/src/components/OidcVisualizer/hooks/useLocalStorage.tsx new file mode 100644 index 00000000..911b1e0f --- /dev/null +++ b/src/components/OidcVisualizer/hooks/useLocalStorage.tsx @@ -0,0 +1,16 @@ +import { useState, useEffect } from 'react'; + +export default function useLocalStorage(key: string, defaultValue: T) { + const [value, setValue] = useState(() => { + if (typeof window === 'undefined') return defaultValue; + const saved = localStorage.getItem(key); + return saved ? JSON.parse(saved) : defaultValue; + }); + + useEffect(() => { + if (typeof window === 'undefined') return; + localStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + return [value, setValue] as const; +} diff --git a/src/components/OidcVisualizer/oidcConfig.tsx b/src/components/OidcVisualizer/oidcConfig.tsx index 370e73ae..85b6b3aa 100644 --- a/src/components/OidcVisualizer/oidcConfig.tsx +++ b/src/components/OidcVisualizer/oidcConfig.tsx @@ -5,6 +5,8 @@ const oidcConfig = { redirectUri: `${typeof window !== 'undefined' ? window.location.origin : 'https://docs.idura.app'}/verify/guides/oidc-visualizer`, scope: 'openid', responseType: 'code', + pkJwtAuth: false, + acrValues: [], }; export default oidcConfig; diff --git a/src/components/OidcVisualizer/types.tsx b/src/components/OidcVisualizer/types.tsx index ff8489eb..1c0fd2c4 100644 --- a/src/components/OidcVisualizer/types.tsx +++ b/src/components/OidcVisualizer/types.tsx @@ -6,3 +6,11 @@ export type OidcTokenResponse = { error?: string; error_description?: string; }; + +export type OidcSettings = { + domain: string; + clientId: string; + clientSecret: string; + scope: string; + acrValues?: string[]; +}; From cdd8c8fbe6b0fbc0dbffc0cd921a567d7cd6bcd6 Mon Sep 17 00:00:00 2001 From: nmoskaleva Date: Tue, 6 Jan 2026 14:55:00 +0100 Subject: [PATCH 3/4] OIDC Visualizer: add Private Key JWT authentication option --- .../OidcVisualizer/OidcVisualizer.tsx | 18 ++- .../components/OidcSettingsModal.tsx | 110 +++++++++++++++--- src/components/OidcVisualizer/generateJwt.tsx | 28 +++++ .../keys/signing_jwks_public.json | 11 ++ .../keys/signing_jwks_public_and_private.json | 17 +++ src/components/OidcVisualizer/types.tsx | 1 + 6 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 src/components/OidcVisualizer/generateJwt.tsx create mode 100644 src/components/OidcVisualizer/keys/signing_jwks_public.json create mode 100644 src/components/OidcVisualizer/keys/signing_jwks_public_and_private.json diff --git a/src/components/OidcVisualizer/OidcVisualizer.tsx b/src/components/OidcVisualizer/OidcVisualizer.tsx index de74f313..0fe990d2 100644 --- a/src/components/OidcVisualizer/OidcVisualizer.tsx +++ b/src/components/OidcVisualizer/OidcVisualizer.tsx @@ -9,6 +9,7 @@ import StepTwo from './components/steps/StepTwo'; import StepThree from './components/steps/StepThree'; import StepFour from './components/steps/StepFour'; import { secondaryBtn } from './styles'; +import { generateJWT } from './generateJwt'; import type { OidcTokenResponse, OidcSettings } from './types'; const OidcVisualizer = () => { @@ -105,17 +106,27 @@ const OidcVisualizer = () => { if (!authCode) return; try { - const baseParams: Record = { + const params: Record = { grant_type: 'authorization_code', code: authCode, redirect_uri: oidcConfig.redirectUri, }; + const clientAssertion = await generateJWT(oidcSettings); + + if (oidcSettings.pkJwtAuth) { + params['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + params['client_assertion'] = clientAssertion; + } else { + params['client_id'] = oidcSettings.clientId; + params['client_secret'] = oidcSettings.clientSecret; + } + const getToken = await fetch(`https://${oidcSettings.domain}/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ - ...baseParams, + ...params, client_id: oidcSettings.clientId, client_secret: oidcSettings.clientSecret, }), @@ -126,7 +137,7 @@ const OidcVisualizer = () => { `Host: ${oidcSettings.domain}`, `Content-Type: application/x-www-form-urlencoded`, ``, - new URLSearchParams(baseParams).toString().replaceAll('&', '\n'), + new URLSearchParams(params).toString().replaceAll('&', '\n'), ].join('\n'); setTokenRequest(formattedRequest); @@ -249,6 +260,7 @@ const OidcVisualizer = () => { scope={oidcSettings.scope} redirectUri={oidcConfig.redirectUri} onSave={handleUpdateSettings} + pkJwtAuth={oidcSettings.pkJwtAuth} acrValues={oidcSettings.acrValues} /> )} diff --git a/src/components/OidcVisualizer/components/OidcSettingsModal.tsx b/src/components/OidcVisualizer/components/OidcSettingsModal.tsx index 413ac9eb..46c9b35f 100644 --- a/src/components/OidcVisualizer/components/OidcSettingsModal.tsx +++ b/src/components/OidcVisualizer/components/OidcSettingsModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { createPortal } from 'react-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { primaryBtn, secondaryBtn, linkStyles, inputStyles, disabledInputStyles } from '../styles'; +import publicSigningKey from '../keys/signing_jwks_public.json'; import { PROVIDERS } from '../../../utils/auth-methods'; import oidcConfig from '../oidcConfig'; @@ -13,6 +14,7 @@ type ModalProps = { clientId: string; clientSecret: string; scope: string; + pkJwtAuth: boolean; acrValues?: string[]; }) => void; domain: string; @@ -21,6 +23,7 @@ type ModalProps = { scope: string; redirectUri: string; acrValues?: string[]; + pkJwtAuth: boolean; }; export default function Modal({ @@ -32,6 +35,7 @@ export default function Modal({ clientSecret, scope, acrValues, + pkJwtAuth, }: ModalProps) { const [settings, setSettings] = useState({ domain, @@ -39,6 +43,7 @@ export default function Modal({ clientSecret, scope, acrValues: acrValues || [], + pkJwtAuth, }); const toggleAcrValue = (value: string) => { @@ -49,6 +54,27 @@ export default function Modal({ }); }; + const authDescriptions = { + client_secret: ( + <> + Standard client authentication using a shared{' '} + + client secret + + . + + ), + private_jwt: ( + <> + A more secure authentication option based on asymmetric cryptography. For more details, see{' '} + + Private key JWT authentication + + . + + ), + }; + return createPortal(
-
-
+
+
+ +
+ +

+ {settings.pkJwtAuth ? authDescriptions.private_jwt : authDescriptions.client_secret} +

+
+
+ {settings.pkJwtAuth && ( +
+ +
+