[CORL-929] SSO Key Rotation (Front-end) (#2877)

* feat: initial impl

* Create preliminary SSO rotation components

CORL-929

* Create rotateSSOKey mutation

CORL-929

* Fix sorting by date for SSO keys

CORL-929

* Show tool tips beside expiring/expired statuses

CORL-929

* Hook up Deactivate and Delete SSO key mutations

CORL-929

* Tweak expired tooltip on SSO keys

CORL-929

* Replace old SSO key config with key rotation

CORL-929

* Fix copy button for SSO key's secret

CORL-929

* Refactor SSOKeyCard func's into components

All the func's building the sub-components
were prime targets for component's with props.

CORL-929

* Update tests to match SSO Key rotation

CORL-929

* Fix typo in translation id

CORL-929

* Test key rotation

CORL-929

* Plumb disabled through SSO key rotation

CORL-929

* Remove duplicate input/payload pairs from schema

For some reason it perfectly duplicated the
input/payload pairs for SSO key rotation on
rebase.

CORL-929

* Remove debug logging

CORL-929

* Use switch instead of if's to compute SSO date field

CORL-929

* Use switch to compute SSO action button state

CORL-929

* Use switch to compute SSO StatusField state

CORL-929

* Remove 10 second key rotation option

CORL-929

* Use memo in sorting SSO keys

CORL-929

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
This commit is contained in:
Nick Funk
2020-03-20 20:59:59 +00:00
committed by GitHub
co-authored by Wyatt Johnson
parent 2dc63d3a27
commit 9a58673545
24 changed files with 1311 additions and 285 deletions
@@ -2,12 +2,13 @@ import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { PropTypesOf } from "coral-framework/types";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { FormFieldDescription } from "coral-ui/components/v2";
import Header from "../../Header";
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
import RegistrationField from "./RegistrationField";
import SSOKeyFieldContainer from "./SSOKeyFieldContainer";
import SSOKeyRotationQuery from "./SSOKeyRotation/SSOKeyRotationQuery";
import TargetFilterField from "./TargetFilterField";
// eslint-disable-next-line no-unused-expressions
@@ -28,10 +29,9 @@ graphql`
interface Props {
disabled?: boolean;
sso: PropTypesOf<typeof SSOKeyFieldContainer>["sso"];
}
const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
const SSOConfig: FunctionComponent<Props> = ({ disabled }) => (
<ConfigBoxWithToggleField
title={
<Localized id="configure-auth-sso-loginWith">
@@ -44,7 +44,23 @@ const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
>
{disabledInside => (
<>
<SSOKeyFieldContainer sso={sso} disabled={disabledInside} />
<Localized
id="configure-auth-sso-description"
IntroLink={
<ExternalLink href="https://jwt.io/introduction/"></ExternalLink>
}
DocLink={
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/sso/"></ExternalLink>
}
>
<FormFieldDescription>
To enable integration with your existing authentication system, you
will need to create a JWT Token to connect. You can learn more about
creating a JWT Token with this introduction. See our documentation
for additional information on single sign on.
</FormFieldDescription>
</Localized>
<SSOKeyRotationQuery disabled={disabledInside}></SSOKeyRotationQuery>
<TargetFilterField
label={
<Localized id="configure-auth-sso-useLoginOn">
@@ -16,17 +16,13 @@ const SSOConfigContainer: React.FunctionComponent<Props> = ({
disabled,
auth,
}) => {
return <SSOConfig disabled={disabled} sso={auth.integrations.sso} />;
return <SSOConfig disabled={disabled} />;
};
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment SSOConfigContainer_auth on Auth {
integrations {
sso {
...SSOKeyFieldContainer_sso
}
}
...SSOConfig_formValues
}
`,
})(SSOConfigContainer);
@@ -1,29 +0,0 @@
.root {
padding-bottom: var(--spacing-4);
}
.keyGenerated {
composes: button from "coral-ui/shared/typography.css";
color: var(--palette-text-secondary);
flex-shrink: 0;
}
.warnIcon {
color: var(--palette-text-secondary);
flex-shrink: 0;
padding-top: 3px;
padding-right: var(--spacing-1);
}
.warn {
color: var(--palette-text-secondary);
}
.warningSection {
padding-top: var(--spacing-1);
padding-bottom: var(--spacing-1);
}
.regenerateButton {
float: right;
}
@@ -1,79 +0,0 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
Button,
Flex,
FormField,
Icon,
Label,
PasswordField,
} from "coral-ui/components/v2";
import HelperText from "../../HelperText";
import styles from "./SSOKeyField.css";
interface Props {
disabled?: boolean;
generatedKey?: string;
keyGeneratedAt?: any;
onRegenerate?: () => void;
}
const SSOKeyField: FunctionComponent<Props> = ({
generatedKey,
keyGeneratedAt,
disabled,
onRegenerate,
}) => (
<FormField className={styles.root}>
<Localized id="configure-auth-sso-key">
<Label htmlFor="configure-auth-sso-key">Key</Label>
</Localized>
<PasswordField
id="configure-auth-sso-key"
name="key"
value={generatedKey}
readOnly
// TODO: (wyattjoh) figure out how to add translations to these props
hidePasswordTitle="Show SSO Key"
showPasswordTitle="Hide SSO Key"
fullWidth
/>
{keyGeneratedAt && (
<Localized
id="configure-auth-sso-regenerateAt"
$date={new Date(keyGeneratedAt)}
>
<HelperText className={styles.keyGenerated}>
KEY GENERATED AT: {keyGeneratedAt}
</HelperText>
</Localized>
)}
<div className={styles.warningSection}>
<Flex direction="row" itemGutter="half">
<Icon className={styles.warnIcon}>warning</Icon>
<Localized id="configure-auth-sso-regenerateHonoredWarning">
<HelperText>
When regenerating a key, tokens signed with the previous key will be
honored for 30 days.
</HelperText>
</Localized>
</Flex>
</div>
<Localized id="configure-auth-sso-regenerate">
<Button
id="configure-auth-sso-regenerate"
disabled={disabled}
onClick={onRegenerate}
className={styles.regenerateButton}
>
Regenerate
</Button>
</Localized>
</FormField>
);
export default SSOKeyField;
@@ -1,60 +0,0 @@
import React from "react";
import { graphql } from "react-relay";
import {
MutationProp,
withFragmentContainer,
withMutation,
} from "coral-framework/lib/relay";
import { SSOKeyFieldContainer_sso as SSOData } from "coral-admin/__generated__/SSOKeyFieldContainer_sso.graphql";
import RegenerateSSOKeyMutation from "./RegenerateSSOKeyMutation";
import SSOKeyField from "./SSOKeyField";
interface Props {
sso: SSOData;
disabled?: boolean;
regenerateSSOKey: MutationProp<typeof RegenerateSSOKeyMutation>;
}
interface State {
awaitingResponse: boolean;
}
class SSOKeyFieldContainer extends React.Component<Props, State> {
public state = {
awaitingResponse: false,
};
private handleRegenerate = async () => {
this.setState({ awaitingResponse: true });
await this.props.regenerateSSOKey();
this.setState({ awaitingResponse: false });
};
public render() {
const { disabled } = this.props;
return (
<SSOKeyField
disabled={disabled || this.state.awaitingResponse}
generatedKey={this.props.sso.key || undefined}
keyGeneratedAt={this.props.sso.keyGeneratedAt || undefined}
onRegenerate={this.handleRegenerate}
/>
);
}
}
const enhanced = withMutation(RegenerateSSOKeyMutation)(
withFragmentContainer<Props>({
sso: graphql`
fragment SSOKeyFieldContainer_sso on SSOAuthIntegration {
key
keyGeneratedAt
}
`,
})(SSOKeyFieldContainer)
);
export default enhanced;
@@ -0,0 +1,10 @@
.label {
padding-bottom: var(--v2-spacing-2);
}
.date {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
font-size: var(--v2-font-size-2);
line-height: var(--v2-line-height-reset);
}
@@ -0,0 +1,97 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Flex, Label } from "coral-ui/components/v2";
import { SSOKeyStatus } from "./StatusField";
import styles from "./DateField.css";
export interface SSOKeyDates {
readonly createdAt: string;
readonly lastUsedAt: string | null;
readonly rotatedAt: string | null;
readonly inactiveAt: string | null;
}
interface Props {
status: SSOKeyStatus;
dates: SSOKeyDates;
}
const DateField: FunctionComponent<Props> = ({ status, dates }) => {
switch (status) {
case SSOKeyStatus.ACTIVE:
return (
<>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-activeSince">
<Label>Active Since</Label>
</Localized>
</div>
<Localized
id="configure-auth-sso-rotate-date"
$date={new Date(dates.createdAt)}
>
<span className={styles.date}>{dates.createdAt}</span>
</Localized>
</>
);
case SSOKeyStatus.EXPIRING:
return (
<>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-inactiveAt">
<Label>Inactive At</Label>
</Localized>
</div>
<Flex
alignItems="center"
justifyContent="center"
className={styles.date}
>
<Localized
id="configure-auth-sso-rotate-date"
$date={
dates.inactiveAt
? new Date(dates.inactiveAt)
: new Date(dates.createdAt)
}
>
{dates.inactiveAt}
</Localized>
</Flex>
</>
);
case SSOKeyStatus.EXPIRED:
return (
<>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-inactiveSince">
<Label>Inactive Since</Label>
</Localized>
</div>
<Flex
alignItems="center"
justifyContent="center"
className={styles.date}
>
<Localized
id="configure-auth-sso-rotate-date"
$date={
dates.inactiveAt
? new Date(dates.inactiveAt)
: new Date(dates.createdAt)
}
>
{dates.inactiveAt}
</Localized>
</Flex>
</>
);
default:
return null;
}
};
export default DateField;
@@ -0,0 +1,52 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { DeactivateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeactivateSSOKeyMutation.graphql";
const clientMutationId = 0;
const DeactivateSSOKeyMutation = createMutation(
"deactivateSSOKey",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeactivateSSOKeyMutation($input: DeactivateSSOKeyInput!) {
deactivateSSOKey(input: $input) {
settings {
auth {
integrations {
sso {
enabled
keys {
kid
secret
createdAt
lastUsedAt
rotatedAt
inactiveAt
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
});
}
);
export default DeactivateSSOKeyMutation;
@@ -0,0 +1,52 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { DeleteSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeleteSSOKeyMutation.graphql";
const clientMutationId = 0;
const DeleteSSOKeyMutation = createMutation(
"deleteSSOKey",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeleteSSOKeyMutation($input: DeleteSSOKeyInput!) {
deleteSSOKey(input: $input) {
settings {
auth {
integrations {
sso {
enabled
keys {
kid
secret
createdAt
lastUsedAt
rotatedAt
inactiveAt
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
});
}
);
export default DeleteSSOKeyMutation;
@@ -0,0 +1,52 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { RotateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/RotateSSOKeyMutation.graphql";
const clientMutationId = 0;
const RotateSSOKeyMutation = createMutation(
"rotateSSOKey",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RotateSSOKeyMutation($input: RotateSSOKeyInput!) {
rotateSSOKey(input: $input) {
settings {
auth {
integrations {
sso {
enabled
keys {
kid
secret
createdAt
lastUsedAt
rotatedAt
inactiveAt
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
});
}
);
export default RotateSSOKeyMutation;
@@ -0,0 +1,3 @@
.rotate {
margin-right: var(--v2-spacing-1)
}
@@ -0,0 +1,72 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
Button,
ClickOutside,
Dropdown,
DropdownButton,
Icon,
Popover,
} from "coral-ui/components/v2";
import RotateOption, { RotateOptions } from "./RotationOption";
import styles from "./RotationDropdown.css";
interface Props {
onRotateKey: (rotation: string) => void;
disabled?: boolean;
}
const RotationDropDown: FunctionComponent<Props> = ({
onRotateKey,
disabled,
}) => {
return (
<Localized
id="configure-auth-sso-rotate-dropdown-description"
attrs={{ description: true }}
>
<Popover
id="sso-key-rotate"
placement="bottom-start"
description="A dropdown to rotate the SSO key"
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Dropdown>
{Object.keys(RotateOptions).map((opt: string) => (
<DropdownButton
key={opt}
onClick={() => {
onRotateKey(opt);
toggleVisibility();
}}
disabled={disabled}
>
<RotateOption value={opt}></RotateOption>
</DropdownButton>
))}
</Dropdown>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<Button
onClick={toggleVisibility}
ref={ref}
color="regular"
disabled={disabled}
>
<Localized id="configure-auth-sso-rotate-rotate">
<span className={styles.rotate}>Rotate</span>
</Localized>
<Icon>arrow_drop_down</Icon>
</Button>
)}
</Popover>
</Localized>
);
};
export default RotationDropDown;
@@ -0,0 +1,46 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
export enum RotateOptions {
NOW = "NOW",
IN1DAY = "IN1DAY",
IN1WEEK = "IN1WEEK",
IN30DAYS = "IN30DAYS",
}
interface Props {
value: string;
}
const RotationOption: FunctionComponent<Props> = ({ value }) => {
switch (value) {
case RotateOptions.NOW: {
return <Localized id="configure-auth-sso-rotate-now">Now</Localized>;
}
case RotateOptions.IN1DAY: {
return (
<Localized id="configure-auth-sso-rotate-1day">
1 day from now
</Localized>
);
}
case RotateOptions.IN1WEEK: {
return (
<Localized id="configure-auth-sso-rotate-1week">
1 week from now
</Localized>
);
}
case RotateOptions.IN30DAYS: {
return (
<Localized id="configure-auth-sso-rotate-30days">
30 days from now
</Localized>
);
}
default:
return <Localized id="configure-auth-sso-rotate-now">Now</Localized>;
}
};
export default RotationOption;
@@ -0,0 +1,23 @@
.label {
padding-bottom: var(--v2-spacing-2);
}
.keySection {
flex-grow: 1;
min-width: 50px;
padding-right: var(--v2-spacing-3);
}
.statusSection {
margin-right: var(--v2-spacing-3);
}
.secretSection {
flex-grow: 1;
min-width: 50px;
}
.action {
margin-right: var(--v2-spacing-1)
}
@@ -0,0 +1,187 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import CopyToClipboard from "react-copy-to-clipboard";
import { useMutation } from "coral-framework/lib/relay";
import {
Button,
Card,
Flex,
HorizontalGutter,
Icon,
Label,
PasswordField,
TextField,
} from "coral-ui/components/v2";
import DateField from "./DateField";
import DeactivateSSOKeyMutation from "./DeactivateSSOKeyMutation";
import DeleteSSOKeyMutation from "./DeleteSSOKeyMutation";
import RotateSSOKeyMutation from "./RotateSSOKeyMutation";
import RotationDropDown from "./RotationDropdown";
import { RotateOptions } from "./RotationOption";
import StatusField, { SSOKeyStatus } from "./StatusField";
import styles from "./SSOKeyCard.css";
export interface SSOKeyDates {
readonly createdAt: string;
readonly lastUsedAt: string | null;
readonly rotatedAt: string | null;
readonly inactiveAt: string | null;
}
interface Props {
id: string;
secret: string;
status: SSOKeyStatus;
dates: SSOKeyDates;
disabled?: boolean;
}
function createActionButton(
status: SSOKeyStatus,
onRotateKey: (rotation: string) => void,
onDeactivateKey: () => void,
onDelete: () => void,
disabled?: boolean
) {
switch (status) {
case SSOKeyStatus.ACTIVE:
return <RotationDropDown onRotateKey={onRotateKey} disabled={disabled} />;
case SSOKeyStatus.EXPIRING:
return (
<Localized id="configure-auth-sso-rotate-deactivateNow">
<Button color="alert" onClick={onDeactivateKey} disabled={disabled}>
Deactivate Now
</Button>
</Localized>
);
case SSOKeyStatus.EXPIRED:
return (
<Localized id="configure-auth-sso-rotate-delete">
<Button color="alert" onClick={onDelete} disabled={disabled}>
Delete
</Button>
</Localized>
);
default:
return null;
}
}
const SSOKeyCard: FunctionComponent<Props> = ({
id,
secret,
status,
dates,
disabled,
}) => {
const rotateSSOKey = useMutation(RotateSSOKeyMutation);
const deactivateSSOKey = useMutation(DeactivateSSOKeyMutation);
const deleteSSOKey = useMutation(DeleteSSOKeyMutation);
const onRotate = useCallback(
(rotation: string) => {
switch (rotation) {
case RotateOptions.NOW:
rotateSSOKey({ inactiveIn: 0 });
break;
case RotateOptions.IN1DAY:
rotateSSOKey({ inactiveIn: 24 * 60 * 60 });
break;
case RotateOptions.IN1WEEK:
rotateSSOKey({ inactiveIn: 7 * 24 * 60 * 60 });
break;
case RotateOptions.IN30DAYS:
rotateSSOKey({ inactiveIn: 30 * 24 * 60 * 60 });
break;
default:
rotateSSOKey({ inactiveIn: 0 });
}
},
[rotateSSOKey]
);
const onDeactivate = useCallback(() => {
deactivateSSOKey({
kid: id,
});
}, [deactivateSSOKey, id]);
const onDelete = useCallback(() => {
deleteSSOKey({
kid: id,
});
}, [deleteSSOKey, id]);
return (
<Card>
<HorizontalGutter>
<Flex alignItems="center" justifyContent="space-between">
<div className={styles.keySection}>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-keyID">
<Label>Key ID</Label>
</Localized>
</div>
<TextField value={id} readOnly fullWidth data-testid="SSO-Key-ID" />
</div>
<div className={styles.secretSection}>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-secret">
<Label>Secret</Label>
</Localized>
</div>
<Flex alignItems="center" justifyContent="flex-start">
<PasswordField
id="configure-auth-sso-rotate-secretField"
name="key"
value={secret}
readOnly
// TODO: (nick-funk) figure out how to add translations to these props
hidePasswordTitle="Show Secret"
showPasswordTitle="Hide Secret"
fullWidth
/>
<CopyToClipboard text={secret}>
<Button color="mono" variant="flat">
<Localized
id="configure-auth-sso-rotate-copySecret"
attrs={{ "aria-label": true }}
>
<Icon size="md" aria-label="Copy Secret">
content_copy
</Icon>
</Localized>
</Button>
</CopyToClipboard>
</Flex>
</div>
</Flex>
<Flex alignItems="flex-end" justifyContent="space-between">
<Flex alignItems="center" justifyContent="flex-start">
<div className={styles.statusSection}>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-status">
<Label>Status</Label>
</Localized>
</div>
<StatusField status={status}></StatusField>
</div>
<div>
<DateField status={status} dates={dates} />
</div>
</Flex>
{createActionButton(
status,
onRotate,
onDeactivate,
onDelete,
disabled
)}
</Flex>
</HorizontalGutter>
</Card>
);
};
export default SSOKeyCard;
@@ -0,0 +1,130 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useMemo } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Label } from "coral-ui/components/v2";
import { SSOKeyRotationContainer_settings } from "coral-admin/__generated__/SSOKeyRotationContainer_settings.graphql";
import SSOKeyCard, { SSOKeyDates } from "./SSOKeyCard";
import { SSOKeyStatus } from "./StatusField";
interface Props {
settings: SSOKeyRotationContainer_settings;
disabled?: boolean;
}
interface Key {
readonly kid: string;
readonly secret: string;
readonly createdAt: string;
readonly lastUsedAt: string | null;
readonly rotatedAt: string | null;
readonly inactiveAt: string | null;
}
function getStatus(dates: SSOKeyDates) {
if (
dates.inactiveAt &&
dates.rotatedAt &&
new Date(dates.inactiveAt) > new Date()
) {
return SSOKeyStatus.EXPIRING;
}
if (dates.inactiveAt && new Date(dates.inactiveAt) <= new Date()) {
return SSOKeyStatus.EXPIRED;
}
return SSOKeyStatus.ACTIVE;
}
const SSOKeyRotationContainer: FunctionComponent<Props> = ({
disabled,
settings,
}) => {
const {
auth: {
integrations: {
sso: { keys },
},
},
} = settings;
const sortedKeys = useMemo(
() =>
keys
// Copy this map because we don't want to modify the underlying copy.
.map(key => key)
.sort((a: Key, b: Key) => {
// Both active, sort on createdAt date.
if (!a.inactiveAt && !b.inactiveAt) {
return (
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
}
// A is active, B is not, A comes before B.
if (!a.inactiveAt && b.inactiveAt) {
return -1;
}
// B is active, A is not, B comes before A.
if (a.inactiveAt && !b.inactiveAt) {
return 1;
}
// Sort primarily on inactiveAt, fall back to createdAt if
// for some reason it's not available.
const aDate = a.inactiveAt
? new Date(a.inactiveAt)
: new Date(a.createdAt);
const bDate = b.inactiveAt
? new Date(b.inactiveAt)
: new Date(b.createdAt);
return bDate.getTime() - aDate.getTime();
}),
[keys]
);
return (
<>
<Localized id="configure-auth-sso-rotate-keys">
<Label htmlFor="configure-auth-sso-rotate-keys">Keys</Label>
</Localized>
{sortedKeys.map(key => (
<SSOKeyCard
key={key.kid}
id={key.kid}
secret={key.secret}
status={getStatus(key)}
dates={key}
disabled={disabled}
/>
))}
</>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment SSOKeyRotationContainer_settings on Settings {
auth {
integrations {
sso {
enabled
keys {
kid
secret
createdAt
lastUsedAt
rotatedAt
inactiveAt
}
}
}
}
}
`,
})(SSOKeyRotationContainer);
export default enhanced;
@@ -0,0 +1,54 @@
import React, { FunctionComponent } from "react";
import {
graphql,
QueryRenderData,
QueryRenderer,
} from "coral-framework/lib/relay";
import { CallOut, Spinner } from "coral-ui/components/v2";
import { SSOKeyRotationQuery as QueryTypes } from "coral-admin/__generated__/SSOKeyRotationQuery.graphql";
import SSOKeyRotationContainer from "./SSOKeyRotationContainer";
interface Props {
disabled?: boolean;
}
const SSOKeyRotationQuery: FunctionComponent<Props> = ({ disabled }) => {
return (
<QueryRenderer<QueryTypes>
query={graphql`
query SSOKeyRotationQuery {
settings {
...SSOKeyRotationContainer_settings
}
}
`}
variables={{}}
cacheConfig={{ force: true }}
render={({ error, props }: QueryRenderData<QueryTypes>) => {
if (error) {
return <CallOut>{error.message}</CallOut>;
}
if (!props) {
return <Spinner />;
}
if (!props.settings) {
return <Spinner />;
}
return (
<SSOKeyRotationContainer
settings={props.settings}
disabled={disabled}
/>
);
}}
/>
);
};
export default SSOKeyRotationQuery;
@@ -0,0 +1,35 @@
.status {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
font-size: var(--v2-font-size-2);
line-height: var(--v2-line-height-reset);
border-radius: 2px;
padding-left: var(--v2-spacing-1);
padding-right: var(--v2-spacing-1);
}
.active {
background-color: var(--v2-colors-green-500);
color: var(--v2-colors-pure-white);
}
.expiring {
background-color: var(--v2-colors-yellow-500);
color: var(--v2-colors-mono-500);
padding-top: var(--v2-spacing-1);
padding-bottom: var(--v2-spacing-1);
}
.expired {
background-color: var(--v2-colors-red-500);
color: var(--v2-colors-pure-white);
padding-top: var(--v2-spacing-1);
padding-bottom: var(--v2-spacing-1);
}
.icon {
padding-right: var(--v2-spacing-1);
}
@@ -0,0 +1,117 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { Flex, Icon, Tooltip, TooltipButton } from "coral-ui/components/v2";
import styles from "./StatusField.css";
export enum SSOKeyStatus {
EXPIRED,
EXPIRING,
ACTIVE,
}
interface Props {
status: SSOKeyStatus;
}
const StatusField: FunctionComponent<Props> = ({ status }) => {
switch (status) {
case SSOKeyStatus.ACTIVE:
return (
<Localized id="configure-auth-sso-rotate-statusActive">
<span
className={cn(styles.status, styles.active)}
data-testid="SSO-Key-Status"
>
Active
</span>
</Localized>
);
case SSOKeyStatus.EXPIRING:
return (
<Flex alignItems="center" justifyContent="center">
<Flex
alignItems="center"
justifyContent="center"
className={cn(styles.status, styles.expiring)}
>
<Icon className={styles.icon}>alarm</Icon>
<Localized id="configure-auth-sso-rotate-statusExpiring">
<span data-testid="SSO-Key-Status">Expiring</span>
</Localized>
</Flex>
<Tooltip
id="configure-auth-sso-rotate-expiringTooltip"
title=""
body={
<Localized id="configure-auth-sso-rotate-expiringTooltip">
<span>
An SSO key is expiring when it is scheduled for rotation.
</span>
</Localized>
}
button={({ toggleVisibility, ref, visible }) => (
<Localized
id="configure-auth-sso-rotate-expiringTooltip-toggleButton"
attrs={{ "aria-label": true }}
>
<TooltipButton
active
aria-label="Toggle expiring tooltip visibility"
toggleVisibility={toggleVisibility}
ref={ref}
/>
</Localized>
)}
/>
</Flex>
);
case SSOKeyStatus.EXPIRED:
return (
<Flex alignItems="center" justifyContent="center">
<Localized id="configure-auth-sso-rotate-statusExpired">
<span
className={cn(styles.status, styles.expired)}
data-testid="SSO-Key-Status"
>
Expired
</span>
</Localized>
<Tooltip
id="configure-auth-sso-rotate-expiredTooltip"
title=""
body={
<Localized id="configure-auth-sso-rotate-expiredTooltip">
<span>
An SSO key is expired when it has been rotated out of use.
</span>
</Localized>
}
button={({ toggleVisibility, ref, visible }) => (
<Localized
id="configure-auth-sso-rotate-expiredTooltip-toggleButton"
attrs={{ "aria-label": true }}
>
<TooltipButton
active
aria-label="Toggle expired tooltip visibility"
toggleVisibility={toggleVisibility}
ref={ref}
/>
</Localized>
)}
/>
</Flex>
);
default:
return (
<Localized id="configure-auth-sso-rotate-statusUnknown">
<span data-testid="SSO-Key-Status">Unknown</span>
</Localized>
);
}
};
export default StatusField;
@@ -1242,86 +1242,233 @@ integration to register for a new account.
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<div
className="Box-root HorizontalGutter-root FormField-root SSOKeyField-root HorizontalGutter-spacing-2"
<p
className="FormFieldDescription-root"
>
<label
className="Label-root"
htmlFor="configure-auth-sso-key"
To enable integration with your existing authentication system,
you will need to create a JWT Token to connect. You can learn
more about creating a JWT Token with
<a
className="ExternalLink-root"
href="https://jwt.io/introduction/"
rel="noopener noreferrer"
target="_blank"
>
Key
</label>
this introduction
</a>
. See our
<a
className="ExternalLink-root"
href="https://docs.coralproject.net/coral/v5/integrating/sso/"
rel="noopener noreferrer"
target="_blank"
>
documentation
</a>
for additional information on single sign on.
</p>
<label
className="Label-root"
htmlFor="configure-auth-sso-rotate-keys"
>
Keys
</label>
<div
className="Card-root"
>
<div
className="PasswordField-fullWidth PasswordField-root"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<div
className="PasswordField-wrapper"
className="Box-root Flex-root Flex-flex Flex-justifySpaceBetween Flex-alignCenter"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
data-testid="password-field"
id="configure-auth-sso-key"
name="key"
placeholder=""
readOnly={true}
spellCheck={false}
type="password"
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Hide SSO Key"
className="SSOKeyCard-keySection"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
<div
className="SSOKeyCard-label"
>
visibility
</i>
<label
className="Label-root"
>
Key ID
</label>
</div>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
data-testid="SSO-Key-ID"
placeholder=""
readOnly={true}
type="text"
value="kid-01"
/>
</div>
</div>
<div
className="SSOKeyCard-secretSection"
>
<div
className="SSOKeyCard-label"
>
<label
className="Label-root"
>
Secret
</label>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignCenter"
>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
data-testid="password-field"
id="configure-auth-sso-rotate-secretField"
name="key"
placeholder=""
readOnly={true}
spellCheck={false}
type="password"
value="secret"
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Hide Secret"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</i>
</div>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorMono Button-variantFlat Button-uppercase"
data-color="mono"
data-variant="flat"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<i
aria-hidden="true"
aria-label="Copy Secret"
className="Icon-root Icon-md"
>
content_copy
</i>
</button>
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-justifySpaceBetween Flex-alignFlexEnd"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignCenter"
>
<div
className="SSOKeyCard-statusSection"
>
<div
className="SSOKeyCard-label"
>
<label
className="Label-root"
>
Status
</label>
</div>
<span
className="StatusField-status StatusField-active"
data-testid="SSO-Key-Status"
>
Active
</span>
</div>
<div>
<div
className="DateField-label"
>
<label
className="Label-root"
>
Active Since
</label>
</div>
<span
className="DateField-date"
>
1/1/2020, 1:00 AM
</span>
</div>
</div>
<div
className="Popover-root"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-disabled"
data-color="regular"
data-variant="regular"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
className="RotationDropdown-rotate"
>
Rotate
</span>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
arrow_drop_down
</i>
</button>
<div
aria-hidden={true}
aria-labelledby="sso-key-rotate-ariainfo"
id="sso-key-rotate"
role="dialog"
>
<div
className="AriaInfo-root"
id="sso-key-rotate-ariainfo"
>
A dropdown to rotate the SSO key
</div>
</div>
</div>
</div>
</div>
<div
className="SSOKeyField-warningSection"
>
<div
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-directionRow gutter"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm SSOKeyField-warnIcon"
>
warning
</i>
<p
className="HelperText-root"
>
When regenerating a key, tokens signed with the previous key will be honored for 30 days.
</p>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-disabled SSOKeyField-regenerateButton"
data-color="regular"
data-variant="regular"
disabled={true}
id="configure-auth-sso-regenerate"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Regenerate
</button>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
@@ -9,6 +9,7 @@ import {
CreateTestRendererParams,
findParentWithType,
replaceHistoryLocation,
toJSON,
wait,
waitForElement,
within,
@@ -57,11 +58,11 @@ it("renders configure auth", async () => {
expect(within(configureContainer).toJSON()).toMatchSnapshot();
});
it("regenerate sso key", async () => {
it("rotate sso key", async () => {
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Mutation: {
regenerateSSOKey: () => {
rotateSSOKey: () => {
return {
settings: pureMerge<typeof settingsWithEmptyAuth>(
settingsWithEmptyAuth,
@@ -69,8 +70,22 @@ it("regenerate sso key", async () => {
auth: {
integrations: {
sso: {
key: "==GENERATED_KEY==",
keyGeneratedAt: "2018-11-12T23:26:06.239Z",
enabled: true,
keys: [
{
kid: "kid-01",
secret: "secret",
createdAt: "2015-01-01T00:00:00.000Z",
lastUsedAt: "2016-01-01T01:45:00.000Z",
rotatedAt: "2016-01-01T01:45:00.000Z",
inactiveAt: "2016-01-01T01:45:00.000Z",
},
{
kid: "kid-02",
secret: "new-secret",
createdAt: "2019-01-01T01:45:00.000Z",
},
],
},
},
},
@@ -90,15 +105,40 @@ it("regenerate sso key", async () => {
act(() => {
within(container)
.getByText("Regenerate", { selector: "button" })
.getByText("Rotate", { selector: "button" })
.props.onClick();
});
await wait(() =>
expect(within(container).getByLabelText("Key").props.value).toBe(
"==GENERATED_KEY=="
)
);
const rotateNow = await waitForElement(() => {
return within(container).getByText("Now", { selector: "button" });
});
act(() => {
rotateNow.props.onClick();
});
await wait(() => {
// Check that we have two SSO Keys that match
// our expected key IDs
const keyIDs = within(container).getAllByTestID("SSO-Key-ID");
const hasOldKey = keyIDs.some(k => k.props.value === "kid-01");
const hasNewKey = keyIDs.some(k => k.props.value === "kid-02");
expect(hasNewKey).toBe(true);
expect(hasOldKey).toBe(true);
const statuses = within(container).getAllByTestID("SSO-Key-Status");
expect(statuses.length).toBe(2);
const firstStatus: any = toJSON(statuses[0]);
const firstStatusIsActive = firstStatus.children.some(
(s: string) => s === "Active"
);
expect(firstStatusIsActive).toBe(true);
const secondStatus: any = toJSON(statuses[1]);
const secondStatusIsActive = secondStatus.children.some(
(s: string) => s === "Active"
);
expect(secondStatusIsActive).toBe(false);
});
});
it("prevents admin lock out", async () => {
+20
View File
@@ -114,6 +114,16 @@ export const settings = createFixture<GQLSettings>({
admin: true,
stream: true,
},
keys: [
{
kid: "kid-01",
secret: "secret",
createdAt: "2020-01-01T01:00:00.000Z",
lastUsedAt: undefined,
rotatedAt: undefined,
inactiveAt: undefined,
},
],
key: "",
keyGeneratedAt: null,
},
@@ -202,6 +212,16 @@ export const settingsWithEmptyAuth = createFixture<GQLSettings>(
stream: true,
},
key: "",
keys: [
{
kid: "kid-01",
secret: "secret",
createdAt: "2020-01-01T01:00:00.000Z",
lastUsedAt: undefined,
rotatedAt: undefined,
inactiveAt: undefined,
},
],
keyGeneratedAt: null,
},
google: {
+28 -28
View File
@@ -5970,6 +5970,34 @@ type DeactivateSSOKeyPayload {
settings: Settings
}
#########################
## deleteSSOKey
#########################
input DeleteSSOKeyInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
kid is the ID of the SSOKey being deleted.
"""
kid: ID!
}
type DeleteSSOKeyPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
settings is the Settings that the SSO key was regenerated on.
"""
settings: Settings
}
#########################
# disableFeatureFlag
#########################
@@ -6064,34 +6092,6 @@ type RemoveStoryExpertPayload {
story: Story!
}
#########################
## deleteSSOKey
#########################
input DeleteSSOKeyInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
kid is the ID of the SSOKey being deleted.
"""
kid: ID!
}
type DeleteSSOKeyPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
settings is the Settings that the SSO key was regenerated on.
"""
settings: Settings
}
#########################
## updateStoryMode
#########################
+45
View File
@@ -427,6 +427,51 @@ configure-auth-sso-regenerateAt = KEY GENERATED AT:
configure-auth-sso-regenerateHonoredWarning =
When regenerating a key, tokens signed with the previous key will be honored for 30 days.
configure-auth-sso-description =
To enable integration with your existing authentication system,
you will need to create a JWT Token to connect. You can learn
more about creating a JWT Token with <IntroLink>this introduction</IntroLink>. See our
<DocLink>documentation</DocLink> for additional information on single sign on.
configure-auth-sso-rotate-keys = Keys
configure-auth-sso-rotate-keyID = Key ID
configure-auth-sso-rotate-secret = Secret
configure-auth-sso-rotate-copySecret =
.aria-label = Copy Secret
configure-auth-sso-rotate-date =
{ DATETIME($date, year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric") }
configure-auth-sso-rotate-activeSince = Active Since
configure-auth-sso-rotate-inactiveAt = Inactive At
configure-auth-sso-rotate-inactiveSince = Inactive Since
configure-auth-sso-rotate-status = Status
configure-auth-sso-rotate-statusActive = Active
configure-auth-sso-rotate-statusExpiring = Expiring
configure-auth-sso-rotate-statusExpired = Expired
configure-auth-sso-rotate-statusUnknown = Unknown
configure-auth-sso-rotate-expiringTooltip =
An SSO key is expiring when it is scheduled for rotation.
configure-auth-sso-rotate-expiringTooltip-toggleButton =
.aria-label = Toggle expiring tooltip visibility
configure-auth-sso-rotate-expiredTooltip =
An SSO key is expired when it has been rotated out of use.
configure-auth-sso-rotate-expiredTooltip-toggleButton =
Toggle expired tooltip visibility
configure-auth-sso-rotate-rotate = Rotate
configure-auth-sso-rotate-deactivateNow = Deactivate Now
configure-auth-sso-rotate-delete = Delete
configure-auth-sso-rotate-now = Now
configure-auth-sso-rotate-10seconds = 10 seconds from now
configure-auth-sso-rotate-1day = 1 day from now
configure-auth-sso-rotate-1week = 1 week from now
configure-auth-sso-rotate-30days = 30 days from now
configure-auth-sso-rotate-dropdown-description =
.description = A dropdown to rotate the SSO key
configure-auth-local-loginWith = Login with email authentication
configure-auth-local-useLoginOn = Use email authentication login on