From a7d0dd55173eb3e2c48ba3474e87c1832759ed7d Mon Sep 17 00:00:00 2001 From: nmoskaleva Date: Tue, 25 Nov 2025 10:04:13 +0100 Subject: [PATCH 1/7] Add OIDC Visualizer --- package-lock.json | 164 +++++---- package.json | 1 + .../OidcVisualizer/OidcVisualizer.tsx | 311 ++++++++++++++++++ .../OidcVisualizer/components/StepCard.tsx | 93 ++++++ src/components/OidcVisualizer/helpers.tsx | 11 + src/components/OidcVisualizer/oidcConfig.tsx | 10 + src/pages/verify/guides/oidc-visualizer.mdx | 14 + 7 files changed, 540 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/helpers.tsx create mode 100644 src/components/OidcVisualizer/oidcConfig.tsx create mode 100644 src/pages/verify/guides/oidc-visualizer.mdx diff --git a/package-lock.json b/package-lock.json index 4e1fb7b..52cb4c5 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 754e7ad..d2794dd 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 0000000..cea3776 --- /dev/null +++ b/src/components/OidcVisualizer/OidcVisualizer.tsx @@ -0,0 +1,311 @@ +import React, { useEffect, useLayoutEffect, useState, useRef } from 'react'; +import { jwtVerify, createRemoteJWKSet, JWTPayload } from 'jose'; +import { formatUrl } from './helpers'; +import StepCard from './components/StepCard'; +import oidcConfig from './oidcConfig'; +import cx from 'classnames'; + +type OidcTokenResponse = { + token_type: 'Bearer'; + expires_in: number; + id_token: string; + access_token: string; + error?: string; + error_description?: string; +}; + +type OidcSettings = { + domain: string; + clientId: string; + clientSecret: string; + scope: string; +}; + +const OidcVisualizer = () => { + const [authCode, setAuthCode] = useState(null); + const [tokenResponse, setTokenResponse] = useState(null); + const [decodedPayload, setDecodedPayload] = 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)}`; + + const baseBtnStyles = + 'px-6 py-4 bg-sky-900 text-white uppercase text-xs font-medium transition hover:bg-sky-700 hover:delay-100'; + + 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); + + /* Define the current step for scrolling */ + 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 center + 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 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 handleLogin = () => { + window.location.href = authorizeUrl; + }; + + 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 */} + + Start + + } + /> + + {/* STEP 2: Code for Token Exchange */} + + Your Authorization Code is:{' '} + + {authCode} + +

+ Now, the authorization code can be exchanged for an access token and an ID token. To + do this, the authorization server sends a POST request to your token endpoint, + including the authorization code and the client credentials. Note that the + authorization code is valid for a single use. +

+ + ) + } + req={ + authCode + ? { + method: 'POST', + url: `/oauth2/token`, + body: { + grant_type: 'authorization_code', + code: authCode, + client_id: oidcConfig.clientId, + }, + } + : null + } + responseId="codeExchangeResponse" + res={tokenResponse} + isActive={!!authCode && !codeExchangeCompleted} + isCompleted={!!tokenResponse && codeExchangeCompleted} + action={ + !tokenResponse ? ( + + ) : ( + + ) + } + error={step2Error} + /> + + {/* STEP 3: Token Verification */} + +

+ The final step is validating the ID Token. We + must confirm the token came from the correct sender and hasn't been tampered with by + verifying its JWT signature. +

+ +

Your id_token is:

+ +
+
{tokenResponse.id_token}
+
+ +

+ This token is cryptographically signed with the HS256 algorithm. We'll use the + client secret to confirm the signature is valid. +

+ + ) + } + isActive={!!tokenResponse && !decodedPayload && codeExchangeCompleted} + isCompleted={!!decodedPayload} + action={ + + } + error={step3Error} + /> + + {/* STEP 4: Result */} + {decodedPayload && ( + +

+ Decoded payload: +

+
+
{JSON.stringify(decodedPayload, null, 2)}
+
+ + } + isActive={!!decodedPayload} + isCompleted={!!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 0000000..4c6757e --- /dev/null +++ b/src/components/OidcVisualizer/components/StepCard.tsx @@ -0,0 +1,93 @@ +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; + action?: React.ReactNode; + isActive?: boolean; + isCompleted?: boolean; + error?: string | null; + responseId?: string; + cardRef?: React.RefObject; +}; + +const StepCard = ({ + number, + title, + description, + req, + res, + action, + isActive, + isCompleted, + error, + responseId, + cardRef, +}: StepCardProps) => { + const opacityClass = isActive || isCompleted ? 'opacity-100' : 'opacity-40 pointer-events-none'; + + return ( +
+
+
+
+ {isCompleted ? : number} +
+

{title}

+
+ +

{description}

+ + {/* Request Section */} + {req && ( +
+ Request +
+
{typeof req === 'string' ? req : JSON.stringify(req, null, 2)}
+
+
+ )} + + {/* 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/helpers.tsx b/src/components/OidcVisualizer/helpers.tsx new file mode 100644 index 0000000..aa5bbe2 --- /dev/null +++ b/src/components/OidcVisualizer/helpers.tsx @@ -0,0 +1,11 @@ +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}`; +} diff --git a/src/components/OidcVisualizer/oidcConfig.tsx b/src/components/OidcVisualizer/oidcConfig.tsx new file mode 100644 index 0000000..370e73a --- /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/pages/verify/guides/oidc-visualizer.mdx b/src/pages/verify/guides/oidc-visualizer.mdx new file mode 100644 index 0000000..1e40f15 --- /dev/null +++ b/src/pages/verify/guides/oidc-visualizer.mdx @@ -0,0 +1,14 @@ +--- +product: verify +category: Guides & Tools +sort: 0 +title: OIDC Visualizer +subtitle: An interactive OpenID Connect sequence visualizer +--- + +import OidcVisualizer from '../../../components/OidcVisualizer/OidcVisualizer'; + +This interactive tool is built to help developers understand [OpenID Connect](/verify/getting-started/glossary/#openid-connect-oidc). +Run the protocol's calls in sequence to see exactly what happens at every step. + + From 4c4a2ca01e18ca4bd55acf00b3023e6c38a4c340 Mon Sep 17 00:00:00 2001 From: nmoskaleva Date: Fri, 19 Dec 2025 10:56:00 +0100 Subject: [PATCH 2/7] fixup! Add OIDC Visualizer --- .../OidcVisualizer/OidcVisualizer.tsx | 180 +++--------------- .../OidcVisualizer/components/StepCard.tsx | 12 +- .../components/steps/StepFour.tsx | 30 +++ .../components/steps/StepOne.tsx | 42 ++++ .../components/steps/StepThree.tsx | 75 ++++++++ .../components/steps/StepTwo.tsx | 86 +++++++++ src/components/OidcVisualizer/helpers.tsx | 11 -- src/components/OidcVisualizer/styles.tsx | 2 + src/components/OidcVisualizer/types.tsx | 8 + src/pages/verify/guides/oidc-visualizer.mdx | 2 +- 10 files changed, 279 insertions(+), 169 deletions(-) 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 delete mode 100644 src/components/OidcVisualizer/helpers.tsx create mode 100644 src/components/OidcVisualizer/styles.tsx create mode 100644 src/components/OidcVisualizer/types.tsx diff --git a/src/components/OidcVisualizer/OidcVisualizer.tsx b/src/components/OidcVisualizer/OidcVisualizer.tsx index cea3776..66cd409 100644 --- a/src/components/OidcVisualizer/OidcVisualizer.tsx +++ b/src/components/OidcVisualizer/OidcVisualizer.tsx @@ -1,25 +1,11 @@ import React, { useEffect, useLayoutEffect, useState, useRef } from 'react'; import { jwtVerify, createRemoteJWKSet, JWTPayload } from 'jose'; -import { formatUrl } from './helpers'; -import StepCard from './components/StepCard'; import oidcConfig from './oidcConfig'; -import cx from 'classnames'; - -type OidcTokenResponse = { - token_type: 'Bearer'; - expires_in: number; - id_token: string; - access_token: string; - error?: string; - error_description?: string; -}; - -type OidcSettings = { - domain: string; - clientId: string; - clientSecret: string; - scope: string; -}; +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 type { OidcTokenResponse } from './types'; const OidcVisualizer = () => { const [authCode, setAuthCode] = useState(null); @@ -31,9 +17,7 @@ const OidcVisualizer = () => { const authorizeUrl = `https://${oidcConfig.domain}/oauth2/authorize?response_type=code&client_id=${oidcConfig.clientId}&redirect_uri=${encodeURIComponent(oidcConfig.redirectUri)}&scope=${encodeURIComponent(oidcConfig.scope)}`; - const baseBtnStyles = - 'px-6 py-4 bg-sky-900 text-white uppercase text-xs font-medium transition hover:bg-sky-700 hover:delay-100'; - + /* Define the current step for scrolling */ const STEP = { STEP_1: 1, STEP_2: 2, @@ -46,7 +30,6 @@ const OidcVisualizer = () => { const step3Ref = useRef(null); const step4Ref = useRef(null); - /* Define the current step for scrolling */ const currentStep = (() => { if (decodedPayload) return STEP.STEP_4; if (tokenResponse && codeExchangeCompleted) return STEP.STEP_3; @@ -74,7 +57,7 @@ const OidcVisualizer = () => { 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 center + // Otherwise, scroll to top window.scrollTo({ top: targetY, behavior: 'smooth', @@ -139,10 +122,6 @@ const OidcVisualizer = () => { } }; - const handleLogin = () => { - window.location.href = authorizeUrl; - }; - const handleReset = () => { setAuthCode(null); setTokenResponse(null); @@ -160,138 +139,37 @@ const OidcVisualizer = () => {
{/* STEP 1: Authorization */} - - Start - - } + { + window.location.href = authorizeUrl; + }} /> {/* STEP 2: Code for Token Exchange */} - - Your Authorization Code is:{' '} - - {authCode} - -

- Now, the authorization code can be exchanged for an access token and an ID token. To - do this, the authorization server sends a POST request to your token endpoint, - including the authorization code and the client credentials. Note that the - authorization code is valid for a single use. -

- - ) - } - req={ - authCode - ? { - method: 'POST', - url: `/oauth2/token`, - body: { - grant_type: 'authorization_code', - code: authCode, - client_id: oidcConfig.clientId, - }, - } - : null - } - responseId="codeExchangeResponse" - res={tokenResponse} - isActive={!!authCode && !codeExchangeCompleted} - isCompleted={!!tokenResponse && codeExchangeCompleted} - action={ - !tokenResponse ? ( - - ) : ( - - ) - } - error={step2Error} + {/* STEP 3: Token Verification */} - -

- The final step is validating the ID Token. We - must confirm the token came from the correct sender and hasn't been tampered with by - verifying its JWT signature. -

- -

Your id_token is:

- -
-
{tokenResponse.id_token}
-
- -

- This token is cryptographically signed with the HS256 algorithm. We'll use the - client secret to confirm the signature is valid. -

- - ) - } - isActive={!!tokenResponse && !decodedPayload && codeExchangeCompleted} - isCompleted={!!decodedPayload} - action={ - - } - error={step3Error} + {/* STEP 4: Result */} - {decodedPayload && ( - -

- Decoded payload: -

-
-
{JSON.stringify(decodedPayload, null, 2)}
-
- - } - isActive={!!decodedPayload} - isCompleted={!!decodedPayload} - /> - )} + {decodedPayload && } {/* Reset Button */} {(decodedPayload || step2Error || step3Error) && ( diff --git a/src/components/OidcVisualizer/components/StepCard.tsx b/src/components/OidcVisualizer/components/StepCard.tsx index 4c6757e..6ccfb5a 100644 --- a/src/components/OidcVisualizer/components/StepCard.tsx +++ b/src/components/OidcVisualizer/components/StepCard.tsx @@ -59,9 +59,9 @@ const StepCard = ({ {req && (
Request -
-
{typeof req === 'string' ? req : JSON.stringify(req, null, 2)}
-
+
+            {typeof req === 'string' ? req : JSON.stringify(req, null, 2)}
+          
)} @@ -72,9 +72,9 @@ const StepCard = ({ id={responseId} // This id will be used for scrolling to the response section on Step 2, once the code exchange is completed > Response -
-
{typeof res === 'string' ? res : JSON.stringify(res, null, 2)}
-
+
+            {typeof res === 'string' ? res : JSON.stringify(res, null, 2)}
+          
)} diff --git a/src/components/OidcVisualizer/components/steps/StepFour.tsx b/src/components/OidcVisualizer/components/steps/StepFour.tsx new file mode 100644 index 0000000..b186c2c --- /dev/null +++ b/src/components/OidcVisualizer/components/steps/StepFour.tsx @@ -0,0 +1,30 @@ +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 0000000..e7e46e1 --- /dev/null +++ b/src/components/OidcVisualizer/components/steps/StepOne.tsx @@ -0,0 +1,42 @@ +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 ( + + Start + + } + /> + ); +} diff --git a/src/components/OidcVisualizer/components/steps/StepThree.tsx b/src/components/OidcVisualizer/components/steps/StepThree.tsx new file mode 100644 index 0000000..771dabb --- /dev/null +++ b/src/components/OidcVisualizer/components/steps/StepThree.tsx @@ -0,0 +1,75 @@ +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, we must check its{' '} + + signature + + . +

+ +

Your id_token is:

+ +
+
{tokenResponse.id_token}
+
+ +

+ The token is cryptographically signed by Idura Verify using the RSA algorithm. 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 0000000..ff7b934 --- /dev/null +++ b/src/components/OidcVisualizer/components/steps/StepTwo.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import StepCard from '../StepCard'; +import { primaryBtn } from '../../styles'; +import oidcConfig from '../../oidcConfig'; +import type { OidcTokenResponse } from '../../types'; +import cx from 'classnames'; + +type StepTwoProps = { + stepRef: React.RefObject; + authCode: string | null; + tokenResponse: OidcTokenResponse | null; + codeExchangeCompleted: boolean; + onCodeExchange: () => void; + proceedToVerifyWT: () => void; + step2Error?: string | null; +}; + +export default function StepTwo({ + stepRef, + authCode, + tokenResponse, + codeExchangeCompleted, + onCodeExchange, + proceedToVerifyWT, + step2Error, +}: StepTwoProps) { + return ( + + Your Authorization Code is:{' '} + + {authCode} + +

+ Now, the authorization code can be exchanged for an access token and an ID token. To + do this, the authorization server sends a POST request to your token endpoint, + including the authorization code and the client credentials. Note that the + authorization code is valid for a single use. +

+ + ) + } + req={ + authCode + ? { + method: 'POST', + url: `/oauth2/token`, + body: { + grant_type: 'authorization_code', + code: authCode, + client_id: oidcConfig.clientId, + }, + } + : null + } + responseId="codeExchangeResponse" + res={tokenResponse} + isActive={!!authCode && !codeExchangeCompleted} + isCompleted={!!tokenResponse && codeExchangeCompleted} + action={ + !tokenResponse ? ( + + ) : ( + + ) + } + error={step2Error} + /> + ); +} diff --git a/src/components/OidcVisualizer/helpers.tsx b/src/components/OidcVisualizer/helpers.tsx deleted file mode 100644 index aa5bbe2..0000000 --- a/src/components/OidcVisualizer/helpers.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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}`; -} diff --git a/src/components/OidcVisualizer/styles.tsx b/src/components/OidcVisualizer/styles.tsx new file mode 100644 index 0000000..ae05719 --- /dev/null +++ b/src/components/OidcVisualizer/styles.tsx @@ -0,0 +1,2 @@ +export const primaryBtn = + 'px-6 py-4 bg-sky-900 text-white uppercase text-xs font-medium transition hover:bg-sky-700 hover:delay-100'; diff --git a/src/components/OidcVisualizer/types.tsx b/src/components/OidcVisualizer/types.tsx new file mode 100644 index 0000000..ff8489e --- /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 index 1e40f15..ae7c989 100644 --- a/src/pages/verify/guides/oidc-visualizer.mdx +++ b/src/pages/verify/guides/oidc-visualizer.mdx @@ -9,6 +9,6 @@ subtitle: An interactive OpenID Connect sequence visualizer import OidcVisualizer from '../../../components/OidcVisualizer/OidcVisualizer'; This interactive tool is built to help developers understand [OpenID Connect](/verify/getting-started/glossary/#openid-connect-oidc). -Run the protocol's calls in sequence to see exactly what happens at every step. +Run the protocol's calls in sequence to see what happens at every step. From ac1380e867908eeaec48b09edc31d2b747da527d Mon Sep 17 00:00:00 2001 From: nmoskaleva Date: Fri, 19 Dec 2025 11:44:37 +0100 Subject: [PATCH 3/7] 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 | 67 +++++++- .../components/OidcSettingsModal.tsx | 148 ++++++++++++++++++ .../OidcVisualizer/hooks/useLocalStorage.tsx | 16 ++ src/components/OidcVisualizer/types.tsx | 7 + 6 files changed, 264 insertions(+), 22 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 52cb4c5..2c821d2 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 d2794dd..367a621 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 66cd409..f5e2b73 100644 --- a/src/components/OidcVisualizer/OidcVisualizer.tsx +++ b/src/components/OidcVisualizer/OidcVisualizer.tsx @@ -1,11 +1,14 @@ 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 type { OidcTokenResponse } from './types'; +import type { OidcTokenResponse, OidcSettings } from './types'; const OidcVisualizer = () => { const [authCode, setAuthCode] = useState(null); @@ -14,8 +17,23 @@ 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)}`; - 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 = { @@ -81,13 +99,13 @@ const OidcVisualizer = () => { if (!authCode) return; try { - const getToken = await fetch(`https://${oidcConfig.domain}/oauth2/token`, { + 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, + client_id: oidcSettings.clientId, + client_secret: oidcSettings.clientSecret, code: authCode, redirect_uri: oidcConfig.redirectUri, }), @@ -113,7 +131,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); @@ -132,10 +150,29 @@ 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 */} @@ -182,6 +219,20 @@ 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} + /> + )}
); }; diff --git a/src/components/OidcVisualizer/components/OidcSettingsModal.tsx b/src/components/OidcVisualizer/components/OidcSettingsModal.tsx new file mode 100644 index 0000000..552a77f --- /dev/null +++ b/src/components/OidcVisualizer/components/OidcSettingsModal.tsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import oidcConfig from '../oidcConfig'; + +type ModalProps = { + open: boolean; + onClose: () => void; + onSave: (newConfig: { + domain: string; + clientId: string; + clientSecret: string; + scope: string; + }) => void; + domain: string; + clientId: string; + clientSecret: string; + scope: string; + redirectUri: string; +}; + +export default function Modal({ + open, + onClose, + onSave, + domain, + clientId, + clientSecret, + scope, +}: ModalProps) { + const [settings, setSettings] = useState({ + domain, + clientId, + clientSecret, + scope, + }); + + 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 })} + /> +
+ +
+ + +
+
+ +
+ + + +
+
+
, + document.body, + ); +} diff --git a/src/components/OidcVisualizer/hooks/useLocalStorage.tsx b/src/components/OidcVisualizer/hooks/useLocalStorage.tsx new file mode 100644 index 0000000..911b1e0 --- /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/types.tsx b/src/components/OidcVisualizer/types.tsx index ff8489e..cf160e8 100644 --- a/src/components/OidcVisualizer/types.tsx +++ b/src/components/OidcVisualizer/types.tsx @@ -6,3 +6,10 @@ export type OidcTokenResponse = { error?: string; error_description?: string; }; + +export type OidcSettings = { + domain: string; + clientId: string; + clientSecret: string; + scope: string; +}; From 84eb2780b1cbcd1fb3b4a9effcc9327900b9aed6 Mon Sep 17 00:00:00 2001 From: nmoskaleva Date: Sat, 20 Dec 2025 13:34:53 +0100 Subject: [PATCH 4/7] OIDC Visualizer: add Private Key JWT authentication option --- .../OidcVisualizer/OidcVisualizer.tsx | 26 +++- .../components/OidcSettingsModal.tsx | 123 +++++++++++++++--- src/components/OidcVisualizer/generateJwt.tsx | 28 ++++ .../keys/signing_jwks_public.json | 11 ++ .../keys/signing_jwks_public_and_private.json | 17 +++ src/components/OidcVisualizer/oidcConfig.tsx | 1 + src/components/OidcVisualizer/styles.tsx | 8 ++ src/components/OidcVisualizer/types.tsx | 1 + 8 files changed, 187 insertions(+), 28 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 f5e2b73..093a7cf 100644 --- a/src/components/OidcVisualizer/OidcVisualizer.tsx +++ b/src/components/OidcVisualizer/OidcVisualizer.tsx @@ -8,6 +8,7 @@ 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 { generateJWT } from './generateJwt'; import type { OidcTokenResponse, OidcSettings } from './types'; const OidcVisualizer = () => { @@ -99,16 +100,26 @@ const OidcVisualizer = () => { if (!authCode) return; try { + 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({ - grant_type: 'authorization_code', - client_id: oidcSettings.clientId, - client_secret: oidcSettings.clientSecret, - code: authCode, - redirect_uri: oidcConfig.redirectUri, - }), + body: new URLSearchParams(params), }); const data: OidcTokenResponse = await getToken.json(); @@ -231,6 +242,7 @@ const OidcVisualizer = () => { scope={oidcSettings.scope} redirectUri={oidcConfig.redirectUri} onSave={handleUpdateSettings} + pkJwtAuth={oidcSettings.pkJwtAuth} /> )}
diff --git a/src/components/OidcVisualizer/components/OidcSettingsModal.tsx b/src/components/OidcVisualizer/components/OidcSettingsModal.tsx index 552a77f..6c91338 100644 --- a/src/components/OidcVisualizer/components/OidcSettingsModal.tsx +++ b/src/components/OidcVisualizer/components/OidcSettingsModal.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { createPortal } from 'react-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { linkStyles, inputStyles, disabledInputStyles } from '../styles'; +import publicSigningKey from '../keys/signing_jwks_public.json'; import oidcConfig from '../oidcConfig'; type ModalProps = { @@ -11,12 +13,14 @@ type ModalProps = { clientId: string; clientSecret: string; scope: string; + pkJwtAuth: boolean; }) => void; domain: string; clientId: string; clientSecret: string; scope: string; redirectUri: string; + pkJwtAuth: boolean; }; export default function Modal({ @@ -27,14 +31,37 @@ export default function Modal({ clientId, clientSecret, scope, + pkJwtAuth, }: ModalProps) { const [settings, setSettings] = useState({ domain, clientId, clientSecret, scope, + pkJwtAuth, }); + 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(
-

Client Configuration

+

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{' '} @@ -65,61 +92,114 @@ export default function Modal({ href="https://dashboard.idura.app/" target="_blank" rel="noopener noreferrer" - className="text-sky-700 underline hover:text-sky-900" + className={linkStyles} > Idura dashboard .

-
-
+
+
+ +
+ +

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

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