Initial version secret rotation (#2646)

This commit is contained in:
Erik Jan de Wit 2022-05-18 11:22:30 +02:00 committed by GitHub
parent d8d28e1d7c
commit e59c6754e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 2483 additions and 8131 deletions

10389
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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?",

View file

@ -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>
)}

View file

@ -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>
)}
</>
);
};

View file

@ -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}
/>

View file

@ -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)}

View file

@ -30,7 +30,7 @@ export const GeneratedCodeTab = ({
id={label}
actions={
<CodeBlockAction>
<CopyToClipboardButton text={text} label={label} />
<CopyToClipboardButton id="code" text={text} label={label} />
</CodeBlockAction>
}
>