Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 51 additions & 11 deletions e2e/decoder.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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) => {
Expand Down
77 changes: 66 additions & 11 deletions e2e/encoder.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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) => {
Expand Down
23 changes: 15 additions & 8 deletions e2e/libraries.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
10 changes: 10 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions src/features/common/components/shell/shell.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,9 +103,11 @@ export const ShellComponent: React.FC<ShellComponentProps> = ({
)}
data-theme={themeCode}
>
<OnetrustScriptComponent
id={CLIENT_CONFIG.DEVELOPERS_DATA_DOMAIN_ID_ONETRUST}
/>
{!ONETRUST_DISABLED && (
<OnetrustScriptComponent
id={CLIENT_CONFIG.DEVELOPERS_DATA_DOMAIN_ID_ONETRUST}
/>
)}
<AdobeAnalyticsScript />
{children}
{consentLevel &&
Expand Down
44 changes: 44 additions & 0 deletions src/features/common/services/asymmetric-key-input.service.ts
Original file line number Diff line number Diff line change
@@ -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;
};
22 changes: 3 additions & 19 deletions src/features/decoder/services/decoder-fragment.service.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading