Initial version secret rotation (#2646)
This commit is contained in:
parent
d8d28e1d7c
commit
e59c6754e9
8 changed files with 2483 additions and 8131 deletions
10389
package-lock.json
generated
10389
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -25,7 +25,7 @@
|
|||
"server:import-client": "./scripts/import-client.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@keycloak/keycloak-admin-client": "^19.0.0-dev.5",
|
||||
"@keycloak/keycloak-admin-client": "^19.0.0-dev.6",
|
||||
"@patternfly/patternfly": "^4.194.4",
|
||||
"@patternfly/react-code-editor": "^4.55.1",
|
||||
"@patternfly/react-core": "^4.214.1",
|
||||
|
|
|
@ -365,6 +365,14 @@
|
|||
"anyAlgorithm": "Any algorithm",
|
||||
"clientSecret": "Client secret",
|
||||
"regenerate": "Regenerate",
|
||||
"secretExpiresOn": "Secret expires on {{time}}",
|
||||
"secretRotated": "Secret rotated",
|
||||
"invalidateSecret": "Invalidate",
|
||||
"secretHasExpired": "Secret has expired, please generate a new one by clicking the \"Regenerate\" button above",
|
||||
"invalidateRotatedSecret": "Invalidate rotated secret?",
|
||||
"invalidateRotatedSecretExplain": "After invalidating rotated secret, the rotated secret will be removed automatically ",
|
||||
"invalidateRotatedSuccess": "Rotated secret successfully removed",
|
||||
"invalidateRotatedError": "Could not remove rotated secret: {{error}}",
|
||||
"confirmClientSecretTitle": "Regenerate secret for this client?",
|
||||
"confirmClientSecretBody": "If you regenerate secret, the Keycloak database will be updated and you will need to download a new adapter for this client.",
|
||||
"confirmAccessTokenTitle": "Regenerate registration access token?",
|
||||
|
|
|
@ -203,6 +203,7 @@ export default function ClientDetails() {
|
|||
|
||||
const form = useForm<ClientRepresentation>({ shouldUnregister: false });
|
||||
const { clientId } = useParams<ClientParams>();
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
const clientAuthenticatorType = useWatch({
|
||||
control: form.control,
|
||||
|
@ -259,7 +260,7 @@ export default function ClientDetails() {
|
|||
setClient(cloneDeep(fetchedClient));
|
||||
setupForm(fetchedClient);
|
||||
},
|
||||
[clientId]
|
||||
[clientId, key]
|
||||
);
|
||||
|
||||
const save = async (
|
||||
|
@ -417,9 +418,10 @@ export default function ClientDetails() {
|
|||
{...route("credentials")}
|
||||
>
|
||||
<Credentials
|
||||
form={form}
|
||||
clientId={clientId}
|
||||
save={() => save()}
|
||||
key={key}
|
||||
client={client}
|
||||
save={save}
|
||||
refresh={() => setKey(key + 1)}
|
||||
/>
|
||||
</Tab>
|
||||
)}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import React from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
|
@ -9,42 +11,139 @@ import {
|
|||
} from "@patternfly/react-core";
|
||||
|
||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||
import type { UseFormMethods } from "react-hook-form";
|
||||
import { PasswordInput } from "../../components/password-input/PasswordInput";
|
||||
import { CopyToClipboardButton } from "../scopes/CopyToClipboardButton";
|
||||
import { useWhoAmI } from "../../context/whoami/WhoAmI";
|
||||
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||
import { useAdminClient } from "../../context/auth/AdminClient";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
|
||||
export type ClientSecretProps = {
|
||||
client: ClientRepresentation;
|
||||
secret: string;
|
||||
toggle: () => void;
|
||||
form: UseFormMethods<ClientRepresentation>;
|
||||
};
|
||||
|
||||
export const ClientSecret = ({ secret, toggle, form }: ClientSecretProps) => {
|
||||
type SecretInputProps = Omit<ClientSecretProps, "client"> & {
|
||||
id: string;
|
||||
buttonLabel: string;
|
||||
};
|
||||
|
||||
const SecretInput = ({ id, buttonLabel, secret, toggle }: SecretInputProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const form = useFormContext<ClientRepresentation>();
|
||||
|
||||
return (
|
||||
<FormGroup label={t("clientSecret")} fieldId="kc-client-secret">
|
||||
<Split hasGutter>
|
||||
<SplitItem isFilled>
|
||||
<InputGroup>
|
||||
<PasswordInput id="kc-client-secret" value={secret} isReadOnly />
|
||||
<CopyToClipboardButton
|
||||
text={secret}
|
||||
label="clientSecret"
|
||||
variant="control"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={form.formState.isDirty}
|
||||
onClick={toggle}
|
||||
>
|
||||
{t("regenerate")}
|
||||
</Button>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
</FormGroup>
|
||||
<Split hasGutter>
|
||||
<SplitItem isFilled>
|
||||
<InputGroup>
|
||||
<PasswordInput id={id} value={secret} isReadOnly />
|
||||
<CopyToClipboardButton
|
||||
id={id}
|
||||
text={secret}
|
||||
label="clientSecret"
|
||||
variant="control"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={form.formState.isDirty}
|
||||
onClick={toggle}
|
||||
>
|
||||
{t(buttonLabel)}
|
||||
</Button>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
);
|
||||
};
|
||||
|
||||
const ExpireDateFormatter = ({ time }: { time: number }) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const { whoAmI } = useWhoAmI();
|
||||
const locale = whoAmI.getLocale();
|
||||
const formatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: "full",
|
||||
timeStyle: "long",
|
||||
}),
|
||||
[locale]
|
||||
);
|
||||
|
||||
const unixTimeToString = (time: number) =>
|
||||
time
|
||||
? t("secretExpiresOn", {
|
||||
time: formatter.format(time * 1000),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return <div className="pf-u-my-md">{unixTimeToString(time)}</div>;
|
||||
};
|
||||
|
||||
export const ClientSecret = ({ client, secret, toggle }: ClientSecretProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
const [secretRotated, setSecretRotated] = useState<string | undefined>(
|
||||
client.attributes?.["client.secret.rotated"]
|
||||
);
|
||||
const secretExpirationTime: number =
|
||||
client.attributes?.["client.secret.expiration.time"];
|
||||
const secretRotatedExpirationTime: number =
|
||||
client.attributes?.["client.secret.rotated.expiration.time"];
|
||||
|
||||
const expired = (time: number) => new Date().getTime() >= time * 1000;
|
||||
|
||||
const [toggleInvalidateConfirm, InvalidateConfirm] = useConfirmDialog({
|
||||
titleKey: "clients:invalidateRotatedSecret",
|
||||
messageKey: "clients:invalidateRotatedSecretExplain",
|
||||
continueButtonLabel: "common:confirm",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await adminClient.clients.invalidateSecret({
|
||||
id: client.id!,
|
||||
});
|
||||
setSecretRotated(undefined);
|
||||
addAlert(t("invalidateRotatedSuccess"));
|
||||
} catch (error) {
|
||||
addError("clients:invalidateRotatedError", error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<InvalidateConfirm />
|
||||
<FormGroup
|
||||
label={t("clientSecret")}
|
||||
fieldId="kc-client-secret"
|
||||
className="pf-u-my-md"
|
||||
>
|
||||
<SecretInput
|
||||
id="kc-client-secret"
|
||||
secret={secret}
|
||||
toggle={toggle}
|
||||
buttonLabel="regenerate"
|
||||
/>
|
||||
<ExpireDateFormatter time={secretExpirationTime} />
|
||||
{expired(secretExpirationTime) && (
|
||||
<Alert variant="warning" isInline title={t("secretHasExpired")} />
|
||||
)}
|
||||
</FormGroup>
|
||||
{secretRotated && (
|
||||
<FormGroup label={t("secretRotated")} fieldId="secretRotated">
|
||||
<SecretInput
|
||||
id="secretRotated"
|
||||
secret={secretRotated}
|
||||
toggle={toggleInvalidateConfirm}
|
||||
buttonLabel="invalidateSecret"
|
||||
/>
|
||||
<ExpireDateFormatter time={secretRotatedExpirationTime} />
|
||||
</FormGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import {
|
||||
Controller,
|
||||
useFormContext,
|
||||
UseFormMethods,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActionGroup,
|
||||
|
@ -22,9 +17,10 @@ import {
|
|||
Split,
|
||||
SplitItem,
|
||||
} from "@patternfly/react-core";
|
||||
|
||||
import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/defs/credentialRepresentation";
|
||||
import type { AuthenticationProviderRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/authenticatorConfigRepresentation";
|
||||
|
||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
|
@ -34,22 +30,22 @@ import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
|||
import { ClientSecret } from "./ClientSecret";
|
||||
import { SignedJWT } from "./SignedJWT";
|
||||
import { X509 } from "./X509";
|
||||
import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation";
|
||||
|
||||
type AccessToken = {
|
||||
registrationAccessToken: string;
|
||||
};
|
||||
|
||||
export type CredentialsProps = {
|
||||
clientId: string;
|
||||
client: ClientRepresentation;
|
||||
save: () => void;
|
||||
form: UseFormMethods<ClientRepresentation>;
|
||||
refresh: () => void;
|
||||
};
|
||||
|
||||
export const Credentials = ({ clientId, save, form }: CredentialsProps) => {
|
||||
export const Credentials = ({ client, save, refresh }: CredentialsProps) => {
|
||||
const { t } = useTranslation("clients");
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const clientId = client.id!;
|
||||
|
||||
const [providers, setProviders] = useState<
|
||||
AuthenticationProviderRepresentation[]
|
||||
|
@ -59,7 +55,7 @@ export const Credentials = ({ clientId, save, form }: CredentialsProps) => {
|
|||
control,
|
||||
formState: { isDirty },
|
||||
handleSubmit,
|
||||
} = useFormContext();
|
||||
} = useFormContext<ClientRepresentation>();
|
||||
|
||||
const clientAuthenticatorType = useWatch({
|
||||
control: control,
|
||||
|
@ -72,21 +68,16 @@ export const Credentials = ({ clientId, save, form }: CredentialsProps) => {
|
|||
const [open, isOpen] = useState(false);
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
const providers =
|
||||
await adminClient.authenticationManagement.getClientAuthenticatorProviders();
|
||||
|
||||
const secret = await adminClient.clients.getClientSecret({
|
||||
id: clientId,
|
||||
});
|
||||
return {
|
||||
providers,
|
||||
secret: secret.value!,
|
||||
};
|
||||
},
|
||||
({ providers, secret }) => {
|
||||
() =>
|
||||
Promise.all([
|
||||
adminClient.authenticationManagement.getClientAuthenticatorProviders(),
|
||||
adminClient.clients.getClientSecret({
|
||||
id: clientId,
|
||||
}),
|
||||
]),
|
||||
([providers, secret]) => {
|
||||
setProviders(providers);
|
||||
setSecret(secret);
|
||||
setSecret(secret.value!);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
@ -111,6 +102,7 @@ export const Credentials = ({ clientId, save, form }: CredentialsProps) => {
|
|||
"clientSecret"
|
||||
);
|
||||
setSecret(secret?.value || "");
|
||||
refresh();
|
||||
};
|
||||
|
||||
const [toggleClientSecretConfirm, ClientSecretConfirm] = useConfirmDialog({
|
||||
|
@ -205,7 +197,7 @@ export const Credentials = ({ clientId, save, form }: CredentialsProps) => {
|
|||
clientAuthenticatorType === "client-secret-jwt") && (
|
||||
<CardBody>
|
||||
<ClientSecret
|
||||
form={form}
|
||||
client={client}
|
||||
secret={secret}
|
||||
toggle={toggleClientSecretConfirm}
|
||||
/>
|
||||
|
|
|
@ -14,11 +14,13 @@ enum CopyState {
|
|||
}
|
||||
|
||||
type CopyToClipboardButtonProps = Pick<ClipboardCopyButtonProps, "variant"> & {
|
||||
id: string;
|
||||
label: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const CopyToClipboardButton = ({
|
||||
id,
|
||||
label,
|
||||
text,
|
||||
variant = "plain",
|
||||
|
@ -56,7 +58,7 @@ export const CopyToClipboardButton = ({
|
|||
|
||||
return (
|
||||
<ClipboardCopyButton
|
||||
id={`copy-button-${label}`}
|
||||
id={`copy-button-${id}`}
|
||||
textId={label}
|
||||
aria-label={t("copyToClipboard")}
|
||||
onClick={() => copyToClipboard(text)}
|
||||
|
|
|
@ -30,7 +30,7 @@ export const GeneratedCodeTab = ({
|
|||
id={label}
|
||||
actions={
|
||||
<CodeBlockAction>
|
||||
<CopyToClipboardButton text={text} label={label} />
|
||||
<CopyToClipboardButton id="code" text={text} label={label} />
|
||||
</CodeBlockAction>
|
||||
}
|
||||
>
|
||||
|
|
Loading…
Reference in a new issue