8000 Enable DPoP for Admin and Account Consoles by edewit · Pull Request #54676 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
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
11 changes: 11 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-27_0_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,17 @@ When upgrading, existing `aes-generated` key providers without an explicit `secr
This ensures the Admin Console displays the actual key size and saving a provider without changes preserves its key and key ID.
Providers with an explicitly configured size are preserved.

=== Admin and Account Consoles use DPoP-bound tokens when advertised
// https://github.com/keycloak/keycloak/issues/42813
// https://github.com/keycloak/keycloak/issues/42814

The Admin Console and Account Console now initialize `keycloak-js` with DPoP in `auto` mode and send authenticated API requests through `keycloak.fetch`.
When the server advertises DPoP support, console access tokens are DPoP-bound and must be presented with a DPoP proof.
Plain `Authorization: Bearer` requests that reuse the console token without a proof are rejected.

If you maintain console extensions or custom themes that call `fetch` with `keycloak.token` directly, switch those calls to `keycloak.fetch` (or an equivalent that attaches a DPoP proof) so they keep working when DPoP is active.
When the server does not advertise DPoP, the consoles continue to use Bearer tokens as before.

=== SAML assertions with a OneTimeUse condition are no longer replayable
// https://github.com/keycloak/keycloak/issues/46325

Expand Down
2 changes: 1 addition & 1 deletion js/apps/account-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"@patternfly/react-table": "^5.4.16",
"i18next": "^25.7.3",
"i18next-fetch-backend": "^7.0.0",
"keycloak-js": "^26.2.2",
"keycloak-js": "^26.2.5",
"lodash-es": "^4.18.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
11 changes: 8 additions & 3 deletions js/apps/account-ui/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type RequestOptions = {

async function _request(
url: URL,
keycloak: Keycloak,
{ signal, getAccessToken, method, searchParams, body }: RequestOptions = {},
): Promise<Response> {
if (searchParams) {
Expand All @@ -25,13 +26,17 @@ async function _request(
);
}

return fetch(url, {
// Refresh first, then read keycloak.token at the last moment so a concurrent
// refresh cannot make keycloak.fetch skip the Bearer→DPoP upgrade.
await getAccessToken?.();

return keycloak.fetch(url, {
signal,
method,
body: body ? JSON.stringify(body) : undefined,
headers: {
[CONTENT_TYPE_HEADER]: CONTENT_TYPE_JSON,
authorization: `Bearer ${await getAccessToken?.()}`,
authorization: `Bearer ${keycloak.token}`,
},
});
}
Expand All @@ -45,7 +50,7 @@ export async function request(
if (typeof fullUrl === "undefined") {
fullUrl = url(environment, path);
}
return _request(fullUrl, {
return _request(fullUrl, keycloak, {
...opts,
getAccessToken: token(keycloak),
});
Expand Down
2 changes: 1 addition & 1 deletion js/apps/admin-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
"i18next": "^25.7.3",
"i18next-fetch-backend": "^7.0.0",
"jszip": "^3.10.1",
"keycloak-js": "^26.2.2",
"keycloak-js": "^26.2.5",
"lodash-es": "^4.18.1",
"p-debounce": "^5.1.0",
"react": "^18.3.1",
Expand Down
6 changes: 5 additions & 1 deletion js/apps/admin-ui/src/admin-client.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import {
createKeycloakFetch,
createNamedContext,
KeycloakSpinner,
useEnvironment,
Expand All @@ -24,9 +25,12 @@ export async function initAdminClient(
keycloak: Keycloak,
environment: Environment,
) {
const adminClient = new KeycloakAdminClient();
const adminClient = new KeycloakAdminClient({
fetch: createKeycloakFetch(keycloak),
});
Comment thread
edewit marked this conversation as resolved.
Resolved

adminClient.setConfig({ realmName: environment.realm });

adminClient.baseUrl = environment.adminBaseUrl;
adminClient.registerTokenProvider({
async getAccessToken() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@

useEffect(() => {
refreshRealm();
}, []);

Check warning on line 73 in js/apps/admin-ui/src/authentication/AuthenticationSection.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

React Hook useEffect has a missing dependency: 'refreshRealm'. Either include it or remove the dependency array
const [key, setKey] = useState(0);
const refresh = () => setKey(key + 1);
const { addAlert, addError } = useAlerts();
Expand All @@ -88,6 +88,7 @@
method: "GET",
headers: getAuthorizationHeaders(await adminClient.getAccessToken()),
},
adminClient.getFetch(),
);
const flows = await flowsRequest.json();

Expand All @@ -102,11 +103,11 @@
};

const useTab = (tab: AuthenticationTab) =>
useRoutableTab(toAuthentication({ realm: realmName, tab }));

Check warning on line 106 in js/apps/admin-ui/src/authentication/AuthenticationSection.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Hooks must be called at the top level in the body of a function component or custom hook, and may not be called within function expressions. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)

const flowsTab = useTab("flows");

Check warning on line 108 in js/apps/admin-ui/src/authentication/AuthenticationSection.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Hooks must be the same function on every render, but this value may change over time to a different function. See https://react.dev/reference/rules/react-calls-components-and-hooks#dont-dynamically-use-hooks
const requiredActionsTab = useTab("required-actions");

Check warning on line 109 in js/apps/admin-ui/src/authentication/AuthenticationSection.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Hooks must be the same function on every render, but this value may change over time to a different function. See https://react.dev/reference/rules/react-calls-components-and-hooks#dont-dynamically-use-hooks
const policiesTab = useTab("policies");

Check warning on line 110 in js/apps/admin-ui/src/authentication/AuthenticationSection.tsx

View workflow job for this annotation

GitHub Actions / Admin UI

Hooks must be the same function on every render, but this value may change over time to a different function. See https://react.dev/reference/rules/react-calls-components-and-hooks#dont-dynamically-use-hooks

const [toggleDeleteDialog, DeleteConfirm] = useConfirmDialog({
titleKey: "deleteConfirmFlow",
Expand Down
1 change: 1 addition & 0 deletions js/apps/admin-ui/src/authentication/RequiredActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const RequiredActions = () => {
method: "GET",
headers: getAuthorizationHeaders(await adminClient.getAccessToken()),
},
adminClient.getFetch(),
);

return (await requiredActionsRequest.json()) as DataType[];
Expand Down
1 change: 1 addition & 0 deletions js/apps/admin-ui/src/clients/import/ImportForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export default function ImportForm() {
body: contents,
headers: getAuthorizationHeaders(await adminClient.getAccessToken()),
},
adminClient.getFetch(),
);

if (!response.ok) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const DownloadDialog = ({
await adminClient.getAccessToken(),
),
},
adminClient.getFetch(),
);

return response.arrayBuffer();
Expand Down
11 changes: 3 additions & 8 deletions js/apps/admin-ui/src/context/auth/admin-ui-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,22 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAdminUI, postAdminUI } from "./admin-ui-endpoint";

describe("admin-ui-endpoint", () => {
let adminClient: KeycloakAdminClient;
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue(new Response("{}"));
adminClient = new KeycloakAdminClient({
baseUrl: "http://localhost:8080",
realmName: "test",
requestOptions: { headers: { "X-Custom-Header": "custom" } },
fetch: fetchMock,
});
adminClient.registerTokenProvider({
getAccessToken: () => Promise.resolve("token"),
});

fetchMock = vi.fn().mockResolvedValue(new Response("{}"));
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
});

const sentHeaders = () => new Headers(fetchMock.mock.calls[0][1].headers);
Expand Down
2 changes: 2 additions & 0 deletions js/apps/admin-ui/src/context/auth/admin-ui-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export async function fetchAdminUI<T>(
method: "GET",
headers: buildHeaders(adminClient, accessToken),
},
adminClient.getFetch(),
);

return await response.json();
Expand Down Expand Up @@ -63,6 +64,7 @@ export async function postAdminUI<T>(
}),
body: JSON.stringify(body),
},
adminClient.getFetch(),
);

const text = await response.text();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const SamlConnectSettings = () => {
body: formData,
headers: getAuthorizationHeaders(await adminClient.getAccessToken()),
},
adminClient.getFetch(),
);
if (response.ok) {
const result = await response.json();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export const RealmSettingsTabs = () => {
...getAuthorizationHeaders(await adminClient.getAccessToken()),
},
},
adminClient.getFetch(),
);
if (!response.ok) throw new Error(response.statusText);
addAlert(t("realmSaveSuccess"), AlertVariant.success);
Expand Down
25 changes: 24 additions & 1 deletion js/libs/keycloak-admin-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { WhoAmI } from "./resources/whoAmI.js";
import { Credentials, getToken, Settings } from "./utils/auth.js";
import { defaultBaseUrl, defaultRealm } from "./utils/constants.js";
import { DecodedToken, decodeToken } from "./utils/decode.js";
import type { FetchLike } from "./utils/fetchWithError.js";

export type RequestOptions = Omit<RequestInit, "signal">;

Expand All @@ -33,6 +34,11 @@ export interface ConnectionConfig {
requestOptions?: RequestOptions;
requestArgOptions?: Pick<RequestArgs, "catchNotFound">;
timeout?: number;
/**
* Custom fetch implementation used for Admin REST calls.
* Pass `keycloak.fetch` to attach DPoP proofs when using keycloak-js.
*/
fetch?: FetchLike;
/**
* Enable experimental APIs (e.g., v2 API).
* These APIs are not yet stable and may change without notice.
Expand Down Expand Up @@ -79,6 +85,7 @@ export class KeycloakAdminClient {
#accessTokenDecoded?: DecodedToken;
#refreshTokenDecoded?: DecodedToken;
#credentials?: Credentials;
#fetch?: FetchLike;

constructor(connectionConfig?: ConnectionConfig) {
this.baseUrl = connectionConfig?.baseUrl || defaultBaseUrl;
Expand All @@ -88,6 +95,8 @@ export class KeycloakAdminClient {
connectionConfig?.enableExperimentalApis ?? false;
this.#requestOptions = connectionConfig?.requestOptions;
this.#globalRequestArgOptions = connectionConfig?.requestArgOptions;
// Resolve the default at request time so later global fetch stubs still apply.
this.#fetch = connectionConfig?.fetch;

// Initialize resources
this.users = new Users(this);
Expand Down Expand Up @@ -211,12 +220,21 @@ export class KeycloakAdminClient {
return this.#requestOptions;
}

public getFetch(): FetchLike {
return this.#fetch ?? fetch;
}

public getGlobalRequestArgOptions():
| Pick<RequestArgs, "catchNotFound">
| undefined {
return this.#globalRequestArgOptions;
}

/**
* Updates connection settings. Omitted `requestOptions` and `fetch` keep their
* previous values; pass a new value to replace them. Neither can be cleared
* through this method once set.
*/
public setConfig(connectionConfig: ConnectionConfig) {
if (
typeof connectionConfig.baseUrl === "string" &&
Expand All @@ -231,6 +249,11 @@ export class KeycloakAdminClient {
) {
this.realmName = connectionConfig.realmName;
}
this.#requestOptions = connectionConfig.requestOptions;
if (connectionConfig.requestOptions !== undefined) {
this.#requestOptions = connectionConfig.requestOptions;
}
if (connectionConfig.fetch !== undefined) {
this.#fetch = connectionConfig.fetch;
}
}
}
20 changes: 12 additions & 8 deletions js/libs/keycloak-admin-client/src/resources/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,14 +245,18 @@ export class Agent {
url.search = stringifyQueryParams(searchParams);

try {
const res = await fetchWithError(url, {
...requestOptions,
headers: requestHeaders,
method,
...(this.#client.timeout
? { signal: AbortSignal.timeout(this.#client.timeout) }
: {}),
});
const res = await fetchWithError(
url,
{
...requestOptions,
headers: requestHeaders,
method,
...(this.#client.timeout
? { signal: AbortSignal.timeout(this.#client.timeout) }
: {}),
},
this.#client.getFetch(),
);

// now we get the response of the http request
// if `resourceIdInLocationHeader` is true, we'll get the resourceId from the location header field
Expand Down
17 changes: 15 additions & 2 deletions js/libs/keycloak-admin-client/src/resources/clientsV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import {
type AuthenticationProvider,
type RequestInformation,
} from "@microsoft/kiota-abstractions";
import { FetchRequestAdapter } from "@microsoft/kiota-http-fetchlibrary";
import {
FetchRequestAdapter,
HttpClient,
} from "@microsoft/kiota-http-fetchlibrary";

import type { KeycloakAdminClient } from "../client.js";
import { createAdminClient } from "../generated/adminClient.js";
Expand Down Expand Up @@ -44,7 +47,17 @@ function createClientsV2Endpoint(
client: KeycloakAdminClient,
): V2RequestBuilder {
const authProvider = new KeycloakAuthProvider(() => client.getAccessToken());
const adapter = new FetchRequestAdapter(authProvider);
const fetchImpl = client.getFetch();
const httpClient =
fetchImpl === fetch
? undefined
: new HttpClient((request, init) => fetchImpl(request, init));
const adapter = new FetchRequestAdapter(
authProvider,
undefined,
undefined,
httpClient,
);
adapter.baseUrl = client.baseUrl;

const adminClient = createAdminClient(adapter);
Expand Down
5 changes: 4 additions & 1 deletion js/libs/keycloak-admin-client/src/utils/fetchWithError.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const ERROR_FIELDS = ["error", "errorMessage"];

export type FetchLike = typeof globalThis.fetch;

export type NetworkErrorOptions = { response: Response; responseData: unknown };

export class NetworkError extends Error {
Expand All @@ -16,8 +18,9 @@ export class NetworkError extends Error {
export async function fetchWithError(
input: Request | string | URL,
init?: RequestInit,
fetchImpl: FetchLike = fetch,
) {
const response = await fetch(input, init);
const response = await fetchImpl(input, init);

if (!response.ok) {
const responseData = await parseResponse(response);
Expand Down
Loading
Loading
0