Prevent slash duplication in request URLs

Closes #44269


(cherry picked from commit f7e4b78f1d717e72a8905b32e95aac3b7bae2e88)

Signed-off-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Jon Koops 2025-11-18 17:19:53 +01:00 committed by GitHub
parent 5d6718354c
commit 7cdca0a8d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 65 additions and 23 deletions

View File

@ -40,7 +40,6 @@
},
"dependencies": {
"camelize-ts": "^3.0.0",
"url-join": "^5.0.0",
"url-template": "^3.1.1"
},
"devDependencies": {

View File

@ -1,4 +1,3 @@
import urlJoin from "url-join";
import { parseTemplate } from "url-template";
import type { KeycloakAdminClient } from "../client.js";
import {
@ -6,6 +5,7 @@ import {
NetworkError,
parseResponse,
} from "../utils/fetchWithError.js";
import { joinPath } from "../utils/joinPath.js";
import { stringifyQueryParams } from "../utils/stringifyQueryParams.js";
// constants
@ -53,7 +53,7 @@ export class Agent {
#client: KeycloakAdminClient;
#basePath: string;
#getBaseParams?: () => Record<string, any>;
#getBaseUrl?: () => string;
#getBaseUrl: () => string;
constructor({
client,
@ -199,12 +199,6 @@ export class Agent {
returnResourceIdInLocationHeader?: { field: string };
headers?: [string, string][] | Record<string, string> | Headers;
}) {
const newPath = urlJoin(this.#basePath, path);
// Parse template and replace with values from urlParams
const pathTemplate = parseTemplate(newPath);
const parsedPath = pathTemplate.expand(urlParams);
const url = new URL(`${this.#getBaseUrl?.() ?? ""}${parsedPath}`);
const requestOptions = { ...this.#client.getRequestOptions() };
const requestHeaders = new Headers([
...new Headers(requestOptions.headers).entries(),
@ -243,6 +237,10 @@ export class Agent {
Object.assign(searchParams, queryParams);
}
const url = new URL(this.#getBaseUrl());
const pathTemplate = parseTemplate(joinPath(this.#basePath, path));
url.pathname = joinPath(url.pathname, pathTemplate.expand(urlParams));
url.search = stringifyQueryParams(searchParams);
try {

View File

@ -1,6 +1,8 @@
import camelize from "camelize-ts";
import { parseTemplate } from "url-template";
import { defaultBaseUrl, defaultRealm } from "./constants.js";
import { fetchWithError } from "./fetchWithError.js";
import { joinPath } from "./joinPath.js";
import { stringifyQueryParams } from "./stringifyQueryParams.js";
export type GrantTypes = "client_credentials" | "password" | "refresh_token";
@ -68,10 +70,17 @@ const encodeFormURIComponent = (data: string) =>
encodeRFC3986URIComponent(data).replaceAll("%20", "+");
export const getToken = async (settings: Settings): Promise<TokenResponse> => {
// Construct URL
const baseUrl = settings.baseUrl || defaultBaseUrl;
const realmName = settings.realmName || defaultRealm;
const url = `${baseUrl}/realms/${realmName}/protocol/openid-connect/token`;
const url = new URL(settings.baseUrl ?? defaultBaseUrl);
const pathTemplate = parseTemplate(
"/realms/{realmName}/protocol/openid-connect/token",
);
url.pathname = joinPath(
url.pathname,
pathTemplate.expand({
realmName: settings.realmName ?? defaultRealm,
}),
);
// Prepare credentials for openid-connect token request
// ref: http://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint

View File

@ -0,0 +1,22 @@
const PATH_SEPARATOR = "/";
export function joinPath(...paths: string[]) {
const normalizedPaths = paths.map((path, index) => {
const isFirst = index === 0;
const isLast = index === paths.length - 1;
// Strip out any leading slashes from the path.
if (!isFirst && path.startsWith(PATH_SEPARATOR)) {
path = path.slice(1);
}
// Strip out any trailing slashes from the path.
if (!isLast && path.endsWith(PATH_SEPARATOR)) {
path = path.slice(0, -1);
}
return path;
}, []);
return normalizedPaths.join(PATH_SEPARATOR);
}

View File

@ -5,8 +5,8 @@ import { KeycloakAdminClient } from "../src/client.js";
import type ClientRepresentation from "../src/defs/clientRepresentation.js";
import type GroupRepresentation from "../src/defs/groupRepresentation.js";
import type RoleRepresentation from "../src/defs/roleRepresentation.js";
import type { SubGroupQuery } from "../src/resources/groups.js";
import { credentials } from "./constants.js";
import { SubGroupQuery } from "../src/resources/groups.js";
const expect = chai.expect;

View File

@ -0,0 +1,23 @@
import { expect } from "chai";
import { joinPath } from "../lib/utils/joinPath.js";
describe("joinPath", () => {
it("returns an empty string when no paths are provided", () => {
expect(joinPath()).to.equal("");
});
it("joins paths", () => {
expect(joinPath("foo", "bar", "baz")).to.equal("foo/bar/baz");
expect(joinPath("foo", "/bar", "baz")).to.equal("foo/bar/baz");
expect(joinPath("foo", "bar/", "baz")).to.equal("foo/bar/baz");
expect(joinPath("foo", "/bar/", "baz")).to.equal("foo/bar/baz");
});
it("joins paths with leading slashes", () => {
expect(joinPath("/foo", "bar", "baz")).to.equal("/foo/bar/baz");
});
it("joins paths with trailing slashes", () => {
expect(joinPath("foo", "bar", "baz/")).to.equal("foo/bar/baz/");
});
});

9
js/pnpm-lock.yaml generated
View File

@ -328,9 +328,6 @@ importers:
camelize-ts:
specifier: ^3.0.0
version: 3.0.0
url-join:
specifier: ^5.0.0
version: 5.0.0
url-template:
specifier: ^3.1.1
version: 3.1.1
@ -4153,10 +4150,6 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
url-join@5.0.0:
resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
url-template@3.1.1:
resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -8516,8 +8509,6 @@ snapshots:
dependencies:
punycode: 2.3.1
url-join@5.0.0: {}
url-template@3.1.1: {}
use-react-router-breadcrumbs@4.0.1(react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):