diff --git a/e2e/decoder.spec.ts b/e2e/decoder.spec.ts index 725a0590..da975511 100644 --- a/e2e/decoder.spec.ts +++ b/e2e/decoder.spec.ts @@ -1,8 +1,5 @@ import { expect, test } from "@playwright/test"; -import { - getButtonsUiDictionary, - getPickersUiDictionary, -} from "@/features/localization/services/ui-language-dictionary.service"; +import { getButtonsUiDictionary } from "@/features/localization/services/ui-language-dictionary.service"; import { DefaultTokensValues, DefaultTokenWithKeysModel, @@ -30,6 +27,7 @@ import { JwtDictionaryModel, JwtSignedWithDigitalModel } from "./e2e.models"; import jwts from "./jwt.json" with { type: "json" }; import { EncodingValues } from "@/features/common/values/encoding.values"; import { isMlDsaAlgorithm } from "@/features/common/values/jws-alg-header-parameter-values.dictionary"; +import { AsymmetricKeyFormatValues } from "@/features/common/values/asymmetric-key-format.values"; const TestJwts = (jwts as JwtDictionaryModel).byAlgorithm; @@ -115,22 +113,18 @@ test.describe("Can generate JWT examples", () => { test.describe("Can generate a JWT decoder example", () => { test.beforeEach(async ({ page }) => { - const lang = await getLang(page); - expectToBeNonNull(lang); - const algorithmPicker = page.getByRole("combobox", { name: "Debugger picker", }); const algorithmPickerRegion = page .getByRole("region") .filter({ has: algorithmPicker }); - const pickersUiDictionary = getPickersUiDictionary(lang); - const pickerIndicator = page.getByText( - pickersUiDictionary.exampleAlgPicker.defaultValue + const pickerControl = algorithmPicker.locator( + 'xpath=ancestor::div[contains(@class, "react-select__control")]', ); await expect(algorithmPickerRegion).toHaveAttribute("aria-busy", "false"); - await pickerIndicator.click(); + await pickerControl.dispatchEvent("mousedown", { button: 0 }); await expect(page.getByRole("listbox")).toBeVisible(); }); @@ -246,6 +240,52 @@ test.describe("decode JWTs", () => { await page.goto(E2E_BASE_URL); }); + test("detects pasted public key formats", async ({ page }) => { + const token = DefaultTokensValues.ES256 as DefaultTokenWithKeysModel; + const { kty, crv, x, y } = token.jwk as JsonWebKey; + const publicKey = { kty, crv, x, y }; + const decoderWidget = page.getByTestId(dataTestidDictionary.decoder.id); + const secretKeyEditor = decoderWidget.getByTestId( + dataTestidDictionary.decoder.secretKeyEditor.id, + ); + const secretKeyEditorInput = secretKeyEditor.getByRole("textbox"); + + await getDecoderJwtEditorInput(page).fill(token.token); + await expect( + page + .locator(".react-select__single-value") + .filter({ hasText: AsymmetricKeyFormatValues.PEM }), + ).toBeVisible(); + + await secretKeyEditorInput.fill(JSON.stringify(publicKey)); + await expect(secretKeyEditorInput).toHaveValue( + JSON.stringify(publicKey, null, 2), + ); + await expect( + page + .locator(".react-select__single-value") + .filter({ hasText: AsymmetricKeyFormatValues.JWK }), + ).toBeVisible(); + await checkSecretKeyDecoderEditorStatusBarMessage({ + page, + type: MessageTypeValue.SUCCESS, + status: MessageStatusValue.VISIBLE, + }); + + await secretKeyEditorInput.fill(token.publicKey); + await expect(secretKeyEditorInput).toHaveValue(token.publicKey); + await expect( + page + .locator(".react-select__single-value") + .filter({ hasText: AsymmetricKeyFormatValues.PEM }), + ).toBeVisible(); + await checkSecretKeyDecoderEditorStatusBarMessage({ + page, + type: MessageTypeValue.SUCCESS, + status: MessageStatusValue.VISIBLE, + }); + }); + const options = Object.keys(DefaultTokensValues); options.forEach((option) => { diff --git a/e2e/encoder.spec.ts b/e2e/encoder.spec.ts index 711db294..fd18ce05 100644 --- a/e2e/encoder.spec.ts +++ b/e2e/encoder.spec.ts @@ -1,8 +1,5 @@ import { expect, test } from "@playwright/test"; -import { - getButtonsUiDictionary, - getPickersUiDictionary, -} from "@/features/localization/services/ui-language-dictionary.service"; +import { getButtonsUiDictionary } from "@/features/localization/services/ui-language-dictionary.service"; import { DefaultTokensValues } from "@/features/common/values/default-tokens.values"; import { dataTestidDictionary } from "@/libs/testing/data-testid.dictionary"; import { @@ -30,6 +27,7 @@ import { import { MessageStatusValue, MessageTypeValue } from "./e2e.values"; import { EncodingValues } from "@/features/common/values/encoding.values"; import { isMlDsaAlgorithm } from "@/features/common/values/jws-alg-header-parameter-values.dictionary"; +import { AsymmetricKeyFormatValues } from "@/features/common/values/asymmetric-key-format.values"; const TestJwts = (jwts as JwtDictionaryModel).byAlgorithm; @@ -234,22 +232,18 @@ test.describe("Generate JWT encoding examples", () => { test.describe("Can generate a JWT example", () => { test.beforeEach(async ({ page }) => { - const lang = await getLang(page); - expectToBeNonNull(lang); - const algorithmPicker = page.getByRole("combobox", { name: "Debugger picker", }); const algorithmPickerRegion = page .getByRole("region") .filter({ has: algorithmPicker }); - const pickersUiDictionary = getPickersUiDictionary(lang); - const pickerIndicator = page.getByText( - pickersUiDictionary.exampleAlgPicker.defaultValue + const pickerControl = algorithmPicker.locator( + 'xpath=ancestor::div[contains(@class, "react-select__control")]', ); await expect(algorithmPickerRegion).toHaveAttribute("aria-busy", "false"); - await pickerIndicator.click(); + await pickerControl.dispatchEvent("mousedown", { button: 0 }); await expect(page.getByRole("listbox")).toBeVisible(); }); @@ -305,6 +299,67 @@ test.describe("encode JWTs", () => { await switchToEncoderTab(page); }); + test("detects pasted private key formats", async ({ page }) => { + const token = TestJwts.ES256 as JwtSignedWithDigitalModel; + const encoderWidget = page.getByTestId(dataTestidDictionary.encoder.id); + const headerEditorInput = encoderWidget + .getByTestId(dataTestidDictionary.encoder.headerEditor.id) + .getByRole("textbox"); + const payloadEditorInput = encoderWidget + .getByTestId(dataTestidDictionary.encoder.payloadEditor.id) + .getByRole("textbox"); + const secretKeyEditorInput = encoderWidget + .getByTestId(dataTestidDictionary.encoder.secretKeyEditor.id) + .getByRole("textbox"); + const jwtOutput = encoderWidget + .getByTestId(dataTestidDictionary.encoder.jwt.id) + .getByRole("textbox"); + + await headerEditorInput.fill(token.withJwkKey.header); + await payloadEditorInput.fill(token.withJwkKey.payload); + await expect( + page + .locator(".react-select__single-value") + .filter({ hasText: AsymmetricKeyFormatValues.PEM }), + ).toBeVisible(); + + await secretKeyEditorInput.fill( + JSON.stringify(token.withJwkKey.privateKey), + ); + await expect(secretKeyEditorInput).toHaveValue( + JSON.stringify(token.withJwkKey.privateKey, null, 2), + ); + await expect( + page + .locator(".react-select__single-value") + .filter({ hasText: AsymmetricKeyFormatValues.JWK }), + ).toBeVisible(); + await checkSecretKeyEncoderEditorStatusBarMessage({ + page, + type: MessageTypeValue.SUCCESS, + status: MessageStatusValue.VISIBLE, + }); + await expect(jwtOutput).toHaveValue( + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + + await secretKeyEditorInput.fill(token.withPemKey.privateKey); + await expect(secretKeyEditorInput).toHaveValue(token.withPemKey.privateKey); + await expect( + page + .locator(".react-select__single-value") + .filter({ hasText: AsymmetricKeyFormatValues.PEM }), + ).toBeVisible(); + await checkSecretKeyEncoderEditorStatusBarMessage({ + page, + type: MessageTypeValue.SUCCESS, + status: MessageStatusValue.VISIBLE, + }); + await expect(jwtOutput).toHaveValue( + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + }); + const options = Object.keys(DefaultTokensValues); options.forEach((option) => { diff --git a/e2e/libraries.spec.ts b/e2e/libraries.spec.ts index 2f70c713..3ad9229b 100644 --- a/e2e/libraries.spec.ts +++ b/e2e/libraries.spec.ts @@ -4,20 +4,27 @@ import { E2E_BASE_URL } from "./e2e.utils"; test("filters libraries by ML-DSA algorithm", async ({ page }) => { await page.goto(`${E2E_BASE_URL}/libraries`); - const filterBy = page.getByRole("combobox", { - name: "Debugger picker", - }); - await expect(filterBy).toBeVisible(); - await page.getByText("All", { exact: true }).click(); + const filterByControl = page + .locator(".react-select__single-value") + .filter({ hasText: "All" }) + .locator('xpath=ancestor::div[contains(@class, "react-select__control")]'); + await filterByControl.click(); + + const listbox = page.getByRole("listbox"); for (const algorithm of ["ML-DSA-44", "ML-DSA-65", "ML-DSA-87"]) { await expect( - page.getByRole("option", { name: algorithm, exact: true }), + listbox.getByRole("option", { name: algorithm, exact: true }), ).toBeVisible(); } - await page.getByRole("option", { name: "ML-DSA-44", exact: true }).click(); + await listbox + .getByRole("option", { name: "ML-DSA-44", exact: true }) + .click(); - await expect(page).toHaveURL(`${E2E_BASE_URL}/libraries?algorithm=ml-dsa-44`); + await expect(page).toHaveURL( + `${E2E_BASE_URL}/libraries?algorithm=ml-dsa-44`, + { timeout: 15_000 }, + ); await expect(page.getByText("panva/jose", { exact: true })).toHaveCount(4); }); diff --git a/next.config.mjs b/next.config.mjs index 8421cd46..bcc9bec9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,6 +2,16 @@ import createMDX from "@next/mdx"; /** @type {import('next').NextConfig} */ const nextConfig = { + // Playwright runs both browser projects against one long-lived dev server. + // Retain route entries so later Firefox tests do not reference evicted chunks. + ...(process.env.CI + ? { + onDemandEntries: { + maxInactiveAge: 10 * 60 * 1000, + pagesBufferLength: 20, + }, + } + : {}), webpack(config) { config.module.rules.push( { diff --git a/playwright.config.ts b/playwright.config.ts index dd933514..2ec3bfa8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -72,7 +72,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'npm run dev', + command: 'NEXT_PUBLIC_DISABLE_ONETRUST=true npm run dev', port: 1234, timeout: 60 * 1000, reuseExistingServer: !process.env.CI, diff --git a/src/features/common/components/shell/shell.component.tsx b/src/features/common/components/shell/shell.component.tsx index 3210b6ec..9e5bb71b 100644 --- a/src/features/common/components/shell/shell.component.tsx +++ b/src/features/common/components/shell/shell.component.tsx @@ -28,6 +28,8 @@ import { AbTestingScriptComponent } from "@/features/analytics/components/ab-tes import AdobeAnalyticsScript from "@/features/analytics/components/adobe-analytics-script.component"; const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID; +const ONETRUST_DISABLED = + process.env.NEXT_PUBLIC_DISABLE_ONETRUST === "true"; interface ShellComponentProps extends PropsWithChildren { languageCode: string; @@ -101,9 +103,11 @@ export const ShellComponent: React.FC = ({ )} data-theme={themeCode} > - + {!ONETRUST_DISABLED && ( + + )} {children} {consentLevel && diff --git a/src/features/common/services/asymmetric-key-input.service.ts b/src/features/common/services/asymmetric-key-input.service.ts new file mode 100644 index 00000000..9108059c --- /dev/null +++ b/src/features/common/services/asymmetric-key-input.service.ts @@ -0,0 +1,44 @@ +import { + parseStringIntoValidJsonObject, + stringifyJsonObject, +} from "@/features/common/services/jwt.service"; +import { AsymmetricKeyFormatValues } from "@/features/common/values/asymmetric-key-format.values"; + +export const detectAsymmetricKeyFormat = ( + value: string, +): AsymmetricKeyFormatValues | null => { + const trimmedValue = value.trim(); + + if (trimmedValue.startsWith("-----BEGIN ")) { + return AsymmetricKeyFormatValues.PEM; + } + + const parseResult = parseStringIntoValidJsonObject(trimmedValue); + + if (parseResult.isOk() && "kty" in parseResult.value) { + return AsymmetricKeyFormatValues.JWK; + } + + return null; +}; + +export const formatAsymmetricKeyInput = ( + value: string, + format: AsymmetricKeyFormatValues, +): string => { + const trimmedValue = value.trim(); + + if (format !== AsymmetricKeyFormatValues.JWK) { + return trimmedValue; + } + + const parseResult = parseStringIntoValidJsonObject(trimmedValue); + + if (parseResult.isErr()) { + return trimmedValue; + } + + const stringifyResult = stringifyJsonObject(parseResult.value); + + return stringifyResult.isOk() ? stringifyResult.value : trimmedValue; +}; diff --git a/src/features/decoder/services/decoder-fragment.service.ts b/src/features/decoder/services/decoder-fragment.service.ts index 5cf554c1..9596988d 100644 --- a/src/features/decoder/services/decoder-fragment.service.ts +++ b/src/features/decoder/services/decoder-fragment.service.ts @@ -1,4 +1,5 @@ import { AsymmetricKeyFormatValues } from "@/features/common/values/asymmetric-key-format.values"; +import { detectAsymmetricKeyFormat } from "@/features/common/services/asymmetric-key-input.service"; const TOKEN_PARAM_PRIORITY = [ "id_token", @@ -21,24 +22,6 @@ export type DecoderFragmentResult = { normalizedHash?: string; }; -const inferPublicKeyFormat = (publicKey: string): AsymmetricKeyFormatValues => { - try { - const parsedPublicKey: unknown = JSON.parse(publicKey); - - if ( - parsedPublicKey !== null && - typeof parsedPublicKey === "object" && - !Array.isArray(parsedPublicKey) - ) { - return AsymmetricKeyFormatValues.JWK; - } - } catch { - // Non-JSON public keys are handled as PEM. - } - - return AsymmetricKeyFormatValues.PEM; -}; - const normalizeLegacyFragment = ( params: URLSearchParams, jwt: string, @@ -96,7 +79,8 @@ export const parseDecoderFragment = (hash: string): DecoderFragmentResult => { if (publicKey) { result.publicKey = publicKey; - result.publicKeyFormat = inferPublicKeyFormat(publicKey); + result.publicKeyFormat = + detectAsymmetricKeyFormat(publicKey) ?? AsymmetricKeyFormatValues.PEM; } if (usesLegacySyntax) { diff --git a/src/features/decoder/services/decoder.store.ts b/src/features/decoder/services/decoder.store.ts index 3cd2c7ac..6a249d39 100644 --- a/src/features/decoder/services/decoder.store.ts +++ b/src/features/decoder/services/decoder.store.ts @@ -9,6 +9,7 @@ import { subscribeWithSelector } from "zustand/middleware"; import { AsymmetricKeyFormatValues } from "@/features/common/values/asymmetric-key-format.values"; import { JwtSignatureStatusValues } from "@/features/common/values/jwt-signature-status.values"; import { DecoderInputsModel } from "@/features/debugger/models/decoder-inputs.model"; +import { detectAsymmetricKeyFormat } from "@/features/common/services/asymmetric-key-input.service"; export enum HashWarningVisibilityValues { VISIBLE = "VISIBLE", @@ -40,6 +41,7 @@ export const DEFAULT_PAYLOAD = { export const DEFAULT_DECODED_PAYLOAD = JSON.stringify(DEFAULT_PAYLOAD, null, 2); let latestJwtChangeRequestId = 0; +let latestAsymmetricPublicKeyRequestId = 0; export type DecoderStoreState = { jwt: string; @@ -220,18 +222,48 @@ export const useDecoderStore = create()( }, handleAsymmetricPublicKeyChange: async (newAsymmetricPublicKey) => { + const requestId = ++latestAsymmetricPublicKeyRequestId; const { jwt, alg, asymmetricPublicKeyFormat } = get(); + const detectedFormat = detectAsymmetricKeyFormat(newAsymmetricPublicKey); + const nextFormat = detectedFormat ?? asymmetricPublicKeyFormat; + + set({ + asymmetricPublicKey: newAsymmetricPublicKey, + ...(nextFormat !== asymmetricPublicKeyFormat + ? { + asymmetricPublicKeyFormat: nextFormat, + controlledAsymmetricPublicKey: { + id: new Date().valueOf(), + value: newAsymmetricPublicKey, + format: nextFormat, + }, + } + : {}), + }); const update = await TokenDecoderService.handleAsymmetricPublicKeyChange({ jwt, alg, - asymmetricPublicKeyFormat, + asymmetricPublicKeyFormat: nextFormat, asymmetricPublicKey: newAsymmetricPublicKey, }); + const currentState = get(); + + if ( + requestId !== latestAsymmetricPublicKeyRequestId || + currentState.jwt !== jwt || + currentState.alg !== alg || + currentState.asymmetricPublicKey !== newAsymmetricPublicKey || + currentState.asymmetricPublicKeyFormat !== nextFormat + ) { + return; + } + set(update); }, handleAsymmetricPublicKeyFormatChange: async (newFormat) => { + const requestId = ++latestAsymmetricPublicKeyRequestId; const { jwt, alg, asymmetricPublicKey, asymmetricPublicKeyFormat } = get(); @@ -247,6 +279,7 @@ export const useDecoderStore = create()( const currentState = get(); if ( + requestId !== latestAsymmetricPublicKeyRequestId || currentState.jwt !== jwt || currentState.alg !== alg || currentState.asymmetricPublicKey !== asymmetricPublicKey || diff --git a/src/features/decoder/services/public-key-input.service.ts b/src/features/decoder/services/public-key-input.service.ts index 8483c0f5..c231ee59 100644 --- a/src/features/decoder/services/public-key-input.service.ts +++ b/src/features/decoder/services/public-key-input.service.ts @@ -1,26 +1 @@ -import { - parseStringIntoValidJsonObject, - stringifyJsonObject, -} from "@/features/common/services/jwt.service"; -import { AsymmetricKeyFormatValues } from "@/features/common/values/asymmetric-key-format.values"; - -export const formatPublicKeyInput = ( - value: string, - format: AsymmetricKeyFormatValues, -): string => { - const trimmedValue = value.trim(); - - if (format !== AsymmetricKeyFormatValues.JWK) { - return trimmedValue; - } - - const parseResult = parseStringIntoValidJsonObject(trimmedValue); - - if (parseResult.isErr()) { - return trimmedValue; - } - - const stringifyResult = stringifyJsonObject(parseResult.value); - - return stringifyResult.isOk() ? stringifyResult.value : trimmedValue; -}; +export { formatAsymmetricKeyInput as formatPublicKeyInput } from "@/features/common/services/asymmetric-key-input.service"; diff --git a/src/features/decoder/services/token-decoder.service.ts b/src/features/decoder/services/token-decoder.service.ts index b8c9577e..3f1715d0 100644 --- a/src/features/decoder/services/token-decoder.service.ts +++ b/src/features/decoder/services/token-decoder.service.ts @@ -947,13 +947,26 @@ class _TokenDecoderService { targetFormat, }); + let convertedPublicKey: string; + if (conversionResult.isErr()) { - return { - verificationInputErrors: [conversionResult.error], - }; + const targetFormatValidationResult = await validateAsymmetricKey({ + alg, + asymmetricPublicKey, + asymmetricPublicKeyFormat: targetFormat, + }); + + if (targetFormatValidationResult.isErr()) { + return { + verificationInputErrors: [conversionResult.error], + }; + } + + convertedPublicKey = asymmetricPublicKey; + } else { + convertedPublicKey = conversionResult.value; } - const convertedPublicKey = conversionResult.value; const stateUpdate: Partial = { asymmetricPublicKey: convertedPublicKey, asymmetricPublicKeyFormat: targetFormat, diff --git a/src/features/encoder/components/token-encoder-input.component.tsx b/src/features/encoder/components/token-encoder-input.component.tsx index 46f63c23..69e81797 100644 --- a/src/features/encoder/components/token-encoder-input.component.tsx +++ b/src/features/encoder/components/token-encoder-input.component.tsx @@ -34,6 +34,7 @@ import { dataTestidDictionary } from "@/libs/testing/data-testid.dictionary"; import { CardToolbarComponent } from "@/features/common/components/card-toolbar/card-toolbar.component"; import { CardToolbarClearButtonComponent } from "@/features/common/components/card-toolbar-buttons/card-toolbar-clear-button/card-toolbar-clear-button.component"; import { EncodingFormatToggleSwitchComponent } from "@/features/decoder/components/encoding-format-toggle-swith/encoding-format-toggle-switch"; +import { formatAsymmetricKeyInput } from "@/features/common/services/asymmetric-key-input.service"; type HeaderInputComponentProps = { languageCode: string; @@ -74,6 +75,9 @@ export const TokenEncoderInputComponent: React.FC< const controlledAsymmetricPrivateKey$ = useEncoderStore( (state) => state.controlledAsymmetricPrivateKey ); + const asymmetricPrivateKeyFormat$ = useEncoderStore( + (state) => state.asymmetricPrivateKeyFormat + ); const [header, setHeader] = useState(DEFAULT_HEADER); const [payload, setPayload] = useState(DEFAULT_PAYLOAD); @@ -96,7 +100,12 @@ export const TokenEncoderInputComponent: React.FC< useEffect(() => { if (controlledAsymmetricPrivateKey$) { - setPrivateKey(controlledAsymmetricPrivateKey$.value); + setPrivateKey( + formatAsymmetricKeyInput( + controlledAsymmetricPrivateKey$.value, + controlledAsymmetricPrivateKey$.format, + ), + ); } }, [controlledAsymmetricPrivateKey$]); @@ -190,7 +199,10 @@ export const TokenEncoderInputComponent: React.FC< ) => { const key = e.target.value; - const cleanValue = key.trim(); + const cleanValue = formatAsymmetricKeyInput( + key, + asymmetricPrivateKeyFormat$, + ); setPrivateKey(cleanValue); diff --git a/src/features/encoder/services/encoder.store.ts b/src/features/encoder/services/encoder.store.ts index 475b8334..b2c7db2b 100644 --- a/src/features/encoder/services/encoder.store.ts +++ b/src/features/encoder/services/encoder.store.ts @@ -12,6 +12,9 @@ import { DEFAULT_SYMMETRIC_SECRET, } from "@/features/encoder/services/encoder.config"; import { EncoderInputsModel } from "@/features/debugger/models/encoder-inputs.model"; +import { detectAsymmetricKeyFormat } from "@/features/common/services/asymmetric-key-input.service"; + +let latestAsymmetricPrivateKeyRequestId = 0; export type EncoderStoreState = { jwt: string | null; @@ -197,20 +200,50 @@ export const useEncoderStore = create()( set(update); }, handleAsymmetricPrivateKeyChange: async (newPrivateKey) => { + const requestId = ++latestAsymmetricPrivateKeyRequestId; const { header, payload, asymmetricPrivateKeyFormat } = get(); + const detectedFormat = detectAsymmetricKeyFormat(newPrivateKey); + const nextFormat = detectedFormat ?? asymmetricPrivateKeyFormat; + + set({ + asymmetricPrivateKey: newPrivateKey, + ...(nextFormat !== asymmetricPrivateKeyFormat + ? { + asymmetricPrivateKeyFormat: nextFormat, + controlledAsymmetricPrivateKey: { + id: new Date().valueOf(), + value: newPrivateKey, + format: nextFormat, + }, + } + : {}), + }); const update = await TokenEncoderService.handleAsymmetricPrivateKeyChange( { header, payload, asymmetricPrivateKey: newPrivateKey, - asymmetricPrivateKeyFormat, + asymmetricPrivateKeyFormat: nextFormat, }, ); + const currentState = get(); + + if ( + requestId !== latestAsymmetricPrivateKeyRequestId || + currentState.header !== header || + currentState.payload !== payload || + currentState.asymmetricPrivateKey !== newPrivateKey || + currentState.asymmetricPrivateKeyFormat !== nextFormat + ) { + return; + } + set(update); }, handleAsymmetricPrivateKeyFormatChange: async (newFormat) => { + const requestId = ++latestAsymmetricPrivateKeyRequestId; const { alg, header, @@ -232,6 +265,7 @@ export const useEncoderStore = create()( const currentState = get(); if ( + requestId !== latestAsymmetricPrivateKeyRequestId || currentState.alg !== alg || currentState.header !== header || currentState.payload !== payload || diff --git a/src/features/encoder/services/token-encoder.service.ts b/src/features/encoder/services/token-encoder.service.ts index a91aae35..08f35617 100644 --- a/src/features/encoder/services/token-encoder.service.ts +++ b/src/features/encoder/services/token-encoder.service.ts @@ -13,6 +13,7 @@ import { convertAsymmetricPrivateKeyFormat, createUnsecuredJwt, getAlgSize, + getAsymmetricKeyCryptoKey, getValidatedEncoderHeader, isDigitalSignatureAlg, isHmacAlg, @@ -1350,13 +1351,26 @@ class _TokenEncoderService { targetFormat: params.targetFormat, }); + let asymmetricPrivateKey: string; + if (conversionResult.isErr()) { - return { - signingErrors: [conversionResult.error], - }; + const targetFormatValidationResult = await getAsymmetricKeyCryptoKey( + params.asymmetricPrivateKey, + params.alg, + params.targetFormat, + ); + + if (targetFormatValidationResult.isErr()) { + return { + signingErrors: [conversionResult.error], + }; + } + + asymmetricPrivateKey = params.asymmetricPrivateKey; + } else { + asymmetricPrivateKey = conversionResult.value; } - const asymmetricPrivateKey = conversionResult.value; const stateUpdate: Partial = { jwt: "", exampleAlg: "", diff --git a/tests/asymmetric-key-input.service.test.ts b/tests/asymmetric-key-input.service.test.ts new file mode 100644 index 00000000..f35dcf1b --- /dev/null +++ b/tests/asymmetric-key-input.service.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; +import { detectAsymmetricKeyFormat } from "@/features/common/services/asymmetric-key-input.service"; +import { AsymmetricKeyFormatValues } from "@/features/common/values/asymmetric-key-format.values"; + +describe("detectAsymmetricKeyFormat", () => { + test.each([ + [ + '{"kty":"EC","crv":"P-256","x":"x-coordinate","y":"y-coordinate"}', + AsymmetricKeyFormatValues.JWK, + ], + [ + "-----BEGIN PUBLIC KEY-----\npublic key\n-----END PUBLIC KEY-----", + AsymmetricKeyFormatValues.PEM, + ], + [ + "-----BEGIN PRIVATE KEY-----\nprivate key\n-----END PRIVATE KEY-----", + AsymmetricKeyFormatValues.PEM, + ], + ])("detects %s", (value, expectedFormat) => { + expect(detectAsymmetricKeyFormat(value)).toBe(expectedFormat); + }); + + test.each(["", "not a key", "{}", "[]", "{invalid"])( + "does not guess the format of %j", + (value) => { + expect(detectAsymmetricKeyFormat(value)).toBeNull(); + }, + ); +}); diff --git a/tests/decoder.store.test.ts b/tests/decoder.store.test.ts index 21eb1d9e..07269773 100644 --- a/tests/decoder.store.test.ts +++ b/tests/decoder.store.test.ts @@ -15,6 +15,19 @@ import { TokenDecoderService } from "@/features/decoder/services/token-decoder.s const rs256 = DefaultTokensValues.RS256 as DefaultTokenWithKeysModel; const rs256PublicJwk = rs256.jwk as { n: string; e: string }; +const es256 = DefaultTokensValues.ES256 as DefaultTokenWithKeysModel; +const es256Jwk = es256.jwk as { + kty: string; + crv: string; + x: string; + y: string; +}; +const es256PublicJwk = { + kty: es256Jwk.kty, + crv: es256Jwk.crv, + x: es256Jwk.x, + y: es256Jwk.y, +}; const deferred = () => { let resolve!: (value: T) => void; @@ -303,8 +316,106 @@ describe("loadDecoderUrlInputs", () => { }); }); +describe("handleAsymmetricPublicKeyChange", () => { + beforeEach(() => { + vi.restoreAllMocks(); + useDecoderStore.setState({ + ...initialState, + jwt: es256.token, + alg: "ES256", + }); + }); + + test("detects a JWK pasted while PEM is selected", async () => { + const publicKey = JSON.stringify(es256PublicJwk, null, 2); + + await useDecoderStore.getState().handleAsymmetricPublicKeyChange(publicKey); + + expect(useDecoderStore.getState()).toMatchObject({ + asymmetricPublicKey: publicKey, + asymmetricPublicKeyFormat: AsymmetricKeyFormatValues.JWK, + controlledAsymmetricPublicKey: { + value: publicKey, + format: AsymmetricKeyFormatValues.JWK, + }, + signatureStatus: JwtSignatureStatusValues.VALID, + verificationInputErrors: null, + }); + }); + + test("detects PEM pasted while JWK is selected", async () => { + useDecoderStore.setState({ + asymmetricPublicKeyFormat: AsymmetricKeyFormatValues.JWK, + }); + + await useDecoderStore + .getState() + .handleAsymmetricPublicKeyChange(es256.publicKey); + + expect(useDecoderStore.getState()).toMatchObject({ + asymmetricPublicKey: es256.publicKey, + asymmetricPublicKeyFormat: AsymmetricKeyFormatValues.PEM, + controlledAsymmetricPublicKey: { + value: es256.publicKey, + format: AsymmetricKeyFormatValues.PEM, + }, + signatureStatus: JwtSignatureStatusValues.VALID, + verificationInputErrors: null, + }); + }); + + test("does not let an earlier key change overwrite a newer one", async () => { + const earlier = deferred>(); + const newer = deferred>(); + const publicJwk = JSON.stringify(es256PublicJwk, null, 2); + const handleAsymmetricPublicKeyChange = vi.spyOn( + TokenDecoderService, + "handleAsymmetricPublicKeyChange", + ); + + handleAsymmetricPublicKeyChange + .mockReturnValueOnce(earlier.promise) + .mockReturnValueOnce(newer.promise); + + const earlierChange = useDecoderStore + .getState() + .handleAsymmetricPublicKeyChange(publicJwk); + const newerChange = useDecoderStore + .getState() + .handleAsymmetricPublicKeyChange(es256.publicKey); + + expect(useDecoderStore.getState()).toMatchObject({ + asymmetricPublicKey: es256.publicKey, + asymmetricPublicKeyFormat: AsymmetricKeyFormatValues.PEM, + controlledAsymmetricPublicKey: { + value: es256.publicKey, + format: AsymmetricKeyFormatValues.PEM, + }, + }); + + newer.resolve({ + asymmetricPublicKey: es256.publicKey, + signatureStatus: JwtSignatureStatusValues.VALID, + }); + await newerChange; + + earlier.resolve({ + asymmetricPublicKey: publicJwk, + signatureStatus: JwtSignatureStatusValues.INVALID, + }); + await earlierChange; + + expect(useDecoderStore.getState()).toMatchObject({ + asymmetricPublicKey: es256.publicKey, + asymmetricPublicKeyFormat: AsymmetricKeyFormatValues.PEM, + signatureStatus: JwtSignatureStatusValues.VALID, + }); + }); +}); + describe("handleAsymmetricPublicKeyFormatChange", () => { beforeEach(() => { + vi.restoreAllMocks(); useDecoderStore.setState({ ...initialState, jwt: rs256.token, @@ -353,6 +464,88 @@ describe("handleAsymmetricPublicKeyFormatChange", () => { }); }); + test("accepts a public key already in the target format", async () => { + const publicKey = JSON.stringify(es256PublicJwk, null, 2); + + useDecoderStore.setState({ + jwt: es256.token, + alg: "ES256", + asymmetricPublicKey: publicKey, + signatureStatus: JwtSignatureStatusValues.INVALID, + }); + + await useDecoderStore + .getState() + .handleAsymmetricPublicKeyFormatChange(AsymmetricKeyFormatValues.JWK); + + expect(useDecoderStore.getState()).toMatchObject({ + asymmetricPublicKey: publicKey, + asymmetricPublicKeyFormat: AsymmetricKeyFormatValues.JWK, + controlledAsymmetricPublicKey: { + value: publicKey, + format: AsymmetricKeyFormatValues.JWK, + }, + signatureStatus: JwtSignatureStatusValues.VALID, + verificationInputErrors: null, + }); + }); + + test("does not let an earlier key change overwrite a newer format change", async () => { + const earlier = deferred>(); + const newer = deferred>(); + const convertedPublicKey = "converted earlier key"; + + vi.spyOn( + TokenDecoderService, + "handleAsymmetricPublicKeyChange", + ).mockReturnValueOnce(earlier.promise); + vi.spyOn( + TokenDecoderService, + "handleAsymmetricPublicKeyFormatChange", + ).mockReturnValueOnce(newer.promise); + + const earlierChange = useDecoderStore + .getState() + .handleAsymmetricPublicKeyChange("earlier key"); + const newerChange = useDecoderStore + .getState() + .handleAsymmetricPublicKeyFormatChange(AsymmetricKeyFormatValues.JWK); + + expect( + TokenDecoderService.handleAsymmetricPublicKeyFormatChange, + ).toHaveBeenCalledWith({ + jwt: rs256.token, + alg: "RS256", + asymmetricPublicKey: "earlier key", + sourceFormat: AsymmetricKeyFormatValues.PEM, + targetFormat: AsymmetricKeyFormatValues.JWK, + }); + + newer.resolve({ + asymmetricPublicKey: convertedPublicKey, + asymmetricPublicKeyFormat: AsymmetricKeyFormatValues.JWK, + controlledAsymmetricPublicKey: { + id: 1, + value: convertedPublicKey, + format: AsymmetricKeyFormatValues.JWK, + }, + signatureStatus: JwtSignatureStatusValues.VALID, + }); + await newerChange; + + earlier.resolve({ + asymmetricPublicKey: "earlier key", + signatureStatus: JwtSignatureStatusValues.INVALID, + }); + await earlierChange; + + expect(useDecoderStore.getState()).toMatchObject({ + asymmetricPublicKey: convertedPublicKey, + asymmetricPublicKeyFormat: AsymmetricKeyFormatValues.JWK, + signatureStatus: JwtSignatureStatusValues.VALID, + }); + }); + test("keeps the current key and format when conversion fails", async () => { useDecoderStore.setState({ asymmetricPublicKey: "not a PEM key", diff --git a/tests/encoder.store.test.ts b/tests/encoder.store.test.ts index 7a2b73b1..a9e60069 100644 --- a/tests/encoder.store.test.ts +++ b/tests/encoder.store.test.ts @@ -1,18 +1,137 @@ -import { beforeEach, describe, expect, test } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { DefaultTokensValues, DefaultTokenWithKeysModel, } from "@/features/common/values/default-tokens.values"; import { AsymmetricKeyFormatValues } from "@/features/common/values/asymmetric-key-format.values"; import { + EncoderStoreState, initialState, useEncoderStore, } from "@/features/encoder/services/encoder.store"; +import { TokenEncoderService } from "@/features/encoder/services/token-encoder.service"; const rs256 = DefaultTokensValues.RS256 as DefaultTokenWithKeysModel; +const es256 = DefaultTokensValues.ES256 as DefaultTokenWithKeysModel; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + return { promise, resolve }; +}; + +describe("handleAsymmetricPrivateKeyChange", () => { + beforeEach(() => { + vi.restoreAllMocks(); + useEncoderStore.setState({ + ...initialState, + alg: "ES256", + header: JSON.stringify({ alg: "ES256", typ: "JWT" }), + }); + }); + + test("detects a JWK pasted while PEM is selected", async () => { + const privateKey = JSON.stringify(es256.jwk, null, 2); + + await useEncoderStore + .getState() + .handleAsymmetricPrivateKeyChange(privateKey); + + expect(useEncoderStore.getState()).toMatchObject({ + asymmetricPrivateKey: privateKey, + asymmetricPrivateKeyFormat: AsymmetricKeyFormatValues.JWK, + controlledAsymmetricPrivateKey: { + value: privateKey, + format: AsymmetricKeyFormatValues.JWK, + }, + signingErrors: null, + }); + expect(useEncoderStore.getState().jwt).toMatch( + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + }); + + test("detects PEM pasted while JWK is selected", async () => { + useEncoderStore.setState({ + asymmetricPrivateKeyFormat: AsymmetricKeyFormatValues.JWK, + }); + + await useEncoderStore + .getState() + .handleAsymmetricPrivateKeyChange(es256.privateKey); + + expect(useEncoderStore.getState()).toMatchObject({ + asymmetricPrivateKey: es256.privateKey, + asymmetricPrivateKeyFormat: AsymmetricKeyFormatValues.PEM, + controlledAsymmetricPrivateKey: { + value: es256.privateKey, + format: AsymmetricKeyFormatValues.PEM, + }, + signingErrors: null, + }); + expect(useEncoderStore.getState().jwt).toMatch( + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + }); + + test("does not let an earlier key change overwrite a newer one", async () => { + const earlier = deferred>(); + const newer = deferred>(); + const privateJwk = JSON.stringify(es256.jwk, null, 2); + const handleAsymmetricPrivateKeyChange = vi.spyOn( + TokenEncoderService, + "handleAsymmetricPrivateKeyChange", + ); + + handleAsymmetricPrivateKeyChange + .mockReturnValueOnce(earlier.promise) + .mockReturnValueOnce(newer.promise); + + const earlierChange = useEncoderStore + .getState() + .handleAsymmetricPrivateKeyChange(privateJwk); + const newerChange = useEncoderStore + .getState() + .handleAsymmetricPrivateKeyChange(es256.privateKey); + + expect(useEncoderStore.getState()).toMatchObject({ + asymmetricPrivateKey: es256.privateKey, + asymmetricPrivateKeyFormat: AsymmetricKeyFormatValues.PEM, + controlledAsymmetricPrivateKey: { + value: es256.privateKey, + format: AsymmetricKeyFormatValues.PEM, + }, + }); + + newer.resolve({ + asymmetricPrivateKey: es256.privateKey, + jwt: "newer.jwt", + signingErrors: null, + }); + await newerChange; + + earlier.resolve({ + asymmetricPrivateKey: privateJwk, + jwt: "earlier.jwt", + signingErrors: ["earlier error"], + }); + await earlierChange; + + expect(useEncoderStore.getState()).toMatchObject({ + asymmetricPrivateKey: es256.privateKey, + asymmetricPrivateKeyFormat: AsymmetricKeyFormatValues.PEM, + jwt: "newer.jwt", + signingErrors: null, + }); + }); +}); describe("handleAsymmetricPrivateKeyFormatChange", () => { beforeEach(() => { + vi.restoreAllMocks(); useEncoderStore.setState({ ...initialState, alg: "RS256", @@ -61,6 +180,103 @@ describe("handleAsymmetricPrivateKeyFormatChange", () => { }); }); + test.each([ + { + name: "JWK", + privateKey: JSON.stringify(rs256.jwk, null, 2), + sourceFormat: AsymmetricKeyFormatValues.PEM, + targetFormat: AsymmetricKeyFormatValues.JWK, + }, + { + name: "PEM", + privateKey: rs256.privateKey, + sourceFormat: AsymmetricKeyFormatValues.JWK, + targetFormat: AsymmetricKeyFormatValues.PEM, + }, + ])( + "accepts a $name private key already in the target format", + async ({ privateKey, sourceFormat, targetFormat }) => { + useEncoderStore.setState({ + asymmetricPrivateKey: privateKey, + asymmetricPrivateKeyFormat: sourceFormat, + }); + + await useEncoderStore + .getState() + .handleAsymmetricPrivateKeyFormatChange(targetFormat); + + expect(useEncoderStore.getState()).toMatchObject({ + asymmetricPrivateKey: privateKey, + asymmetricPrivateKeyFormat: targetFormat, + controlledAsymmetricPrivateKey: { + value: privateKey, + format: targetFormat, + }, + signingErrors: null, + }); + }, + ); + + test("does not let an earlier key change overwrite a newer format change", async () => { + const earlier = deferred>(); + const newer = deferred>(); + const convertedPrivateKey = "converted earlier key"; + + vi.spyOn( + TokenEncoderService, + "handleAsymmetricPrivateKeyChange", + ).mockReturnValueOnce(earlier.promise); + vi.spyOn( + TokenEncoderService, + "handleAsymmetricPrivateKeyFormatChange", + ).mockReturnValueOnce(newer.promise); + + const earlierChange = useEncoderStore + .getState() + .handleAsymmetricPrivateKeyChange("earlier key"); + const newerChange = useEncoderStore + .getState() + .handleAsymmetricPrivateKeyFormatChange(AsymmetricKeyFormatValues.JWK); + + expect( + TokenEncoderService.handleAsymmetricPrivateKeyFormatChange, + ).toHaveBeenCalledWith({ + alg: "RS256", + header: JSON.stringify({ alg: "RS256", typ: "JWT" }), + payload: initialState.payload, + asymmetricPrivateKey: "earlier key", + sourceFormat: AsymmetricKeyFormatValues.PEM, + targetFormat: AsymmetricKeyFormatValues.JWK, + }); + + newer.resolve({ + asymmetricPrivateKey: convertedPrivateKey, + asymmetricPrivateKeyFormat: AsymmetricKeyFormatValues.JWK, + controlledAsymmetricPrivateKey: { + id: 1, + value: convertedPrivateKey, + format: AsymmetricKeyFormatValues.JWK, + }, + jwt: "newer.jwt", + signingErrors: null, + }); + await newerChange; + + earlier.resolve({ + asymmetricPrivateKey: "earlier key", + jwt: "earlier.jwt", + signingErrors: ["earlier error"], + }); + await earlierChange; + + expect(useEncoderStore.getState()).toMatchObject({ + asymmetricPrivateKey: convertedPrivateKey, + asymmetricPrivateKeyFormat: AsymmetricKeyFormatValues.JWK, + jwt: "newer.jwt", + signingErrors: null, + }); + }); + test("keeps the current key and format when conversion fails", async () => { useEncoderStore.setState({ asymmetricPrivateKey: "not a PEM key",