Skip to content
Draft
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
6 changes: 2 additions & 4 deletions .github/workflows/backend_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,7 @@ jobs:
DYNAMODB_ACCESS_KEY_ID: op://github-actions/dynamodb/DYNAMODB_ACCESS_KEY_ID
DYNAMODB_ACCESS_KEY: op://github-actions/dynamodb/DYNAMODB_ACCESS_KEY
DYNAMODB_REGION: op://github-actions/dynamodb/DYNAMODB_REGION
OKTA_CLIENT_ID: op://github-actions/okta/OKTA_CLIENT_ID
OKTA_PRIVATE_KEY: op://github-actions/okta/OKTA_PRIVATE_KEY
OKTA_CLIENT_TOKEN: op://github-actions/ctl/OKTA_CLIENT_TOKEN
REDSHIFT_FIDESCTL_PASSWORD: op://github-actions/ctl/REDSHIFT_FIDESCTL_PASSWORD
SNOWFLAKE_FIDESCTL_PASSWORD: op://github-actions/ctl/SNOWFLAKE_FIDESCTL_PASSWORD

Expand Down Expand Up @@ -470,9 +469,8 @@ jobs:
GOOGLE_CLOUD_SQL_POSTGRES_DB_IAM_USER: op://github-actions/gcp-postgres/GOOGLE_CLOUD_SQL_POSTGRES_DB_IAM_USER
GOOGLE_CLOUD_SQL_POSTGRES_INSTANCE_CONNECTION_NAME: op://github-actions/gcp-postgres/GOOGLE_CLOUD_SQL_POSTGRES_INSTANCE_CONNECTION_NAME
GOOGLE_CLOUD_SQL_POSTGRES_KEYFILE_CREDS: op://github-actions/gcp-postgres/GOOGLE_CLOUD_SQL_POSTGRES_KEYFILE_CREDS
OKTA_CLIENT_ID: op://github-actions/okta/OKTA_CLIENT_ID
OKTA_API_TOKEN: op://github-actions/okta/OKTA_API_TOKEN
OKTA_ORG_URL: op://github-actions/okta/OKTA_ORG_URL
OKTA_PRIVATE_KEY: op://github-actions/okta/OKTA_PRIVATE_KEY
RDS_MYSQL_AWS_ACCESS_KEY_ID: op://github-actions/rds-mysql/RDS_MYSQL_AWS_ACCESS_KEY_ID
RDS_MYSQL_AWS_SECRET_ACCESS_KEY: op://github-actions/rds-mysql/RDS_MYSQL_AWS_SECRET_ACCESS_KEY
RDS_MYSQL_DB_INSTANCE: op://github-actions/rds-mysql/RDS_MYSQL_DB_INSTANCE
Expand Down
8 changes: 2 additions & 6 deletions clients/admin-ui/cypress/e2e/config-wizard.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,8 @@ describe("Config Wizard", () => {
cy.getByTestId("okta-btn").click();
// Fill form
cy.getByTestId("authenticate-okta-form");
cy.getByTestId("input-orgUrl").type("https://dev-12345.okta.com");
cy.getByTestId("input-clientId").type("0oa1abc2def3ghi4jkl5");
cy.getByTestId("input-privateKey").type(
'{"kty":"RSA","kid":"test","n":"test","e":"AQAB","d":"test"}',
{ parseSpecialCharSequences: false },
);
cy.getByTestId("input-orgUrl").type("https://ethyca.com/");
cy.getByTestId("input-token").type("fakeToken");
});

it("Allows submitting the form and reviewing the results", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "~/features/connection-type/types";

import { ControlledSelect } from "./ControlledSelect";
import { CustomTextArea, CustomTextInput } from "./inputs";
import { CustomTextInput } from "./inputs";

export type FormFieldProps = {
name: string;
Expand Down Expand Up @@ -91,23 +91,6 @@ export const FormFieldFromSchema = ({
);
}

if (fieldSchema.multiline) {
return (
<CustomTextArea
{...field}
label={fieldSchema.title}
tooltip={fieldSchema.description}
isRequired={isRequired}
placeholder={getPlaceholder()}
variant={layout}
textAreaProps={{
rows: 8,
style: { fontFamily: "monospace", fontSize: "12px" },
}}
/>
);
}

return (
<CustomTextInput
{...field}
Expand Down
226 changes: 167 additions & 59 deletions clients/admin-ui/src/features/config-wizard/AuthenticateOktaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { useState } from "react";
import * as Yup from "yup";

import { useAppDispatch, useAppSelector } from "~/app/hooks";
import { useFlags } from "~/features/common/features";
import { CustomTextArea, CustomTextInput } from "~/features/common/form/inputs";
import {
isErrorResult,
ParsedError,
parseError,
} from "~/features/common/helpers";
import { useAlert } from "~/features/common/hooks";
import { OKTA_AUTH_DESCRIPTION } from "~/features/integrations/integration-type-info/oktaInfo";
import {
GenerateResponse,
GenerateTypes,
Expand All @@ -32,24 +32,36 @@ import { useGenerateMutation } from "./scanner.slice";
import ScannerError from "./ScannerError";
import ScannerLoading from "./ScannerLoading";

// OAuth2 config type for Okta authentication
// OAuth2 config type for new authentication method
type OktaOAuth2Config = {
orgUrl: string;
clientId: string;
privateKey: string;
scopes: string[];
};

const initialValues = {
// Token-based config for legacy authentication
type OktaTokenConfig = {
orgUrl: string;
token: string;
};

const oauth2InitialValues = {
orgUrl: "",
clientId: "",
privateKey: "",
scopes: "okta.apps.read",
};

type FormValues = typeof initialValues;
const tokenInitialValues = {
orgUrl: "",
token: "",
};

type OAuth2FormValues = typeof oauth2InitialValues;
type TokenFormValues = typeof tokenInitialValues;

const ValidationSchema = Yup.object().shape({
const OAuth2ValidationSchema = Yup.object().shape({
orgUrl: Yup.string().required().trim().url().label("Organization URL"),
clientId: Yup.string()
.required()
Expand All @@ -60,47 +72,37 @@ const ValidationSchema = Yup.object().shape({
.required()
.trim()
.test(
"is-valid-json",
"Private key must be valid JSON. Paste the JWK downloaded from Okta.",
(value) => {
if (!value) {
return true;
}
try {
JSON.parse(value);
return true;
} catch {
return false;
}
},
"is-valid-key",
"Private key must be in PEM format (starts with -----BEGIN RSA PRIVATE KEY-----)",
(value) => !value || value.includes("-----BEGIN"),
)
.label("Private Key"),
scopes: Yup.string()
.required()
.trim()
.label("Scopes")
.default("okta.apps.read")
.test(
"valid-scopes",
"Scopes must be a single scope or comma-separated list (e.g., 'okta.apps.read' or 'okta.apps.read, okta.users.read')",
(value) => {
if (!value) {
return true;
}
// Split on comma and check each scope is non-empty and has no internal whitespace
const scopes = value.split(",").map((s) => s.trim());
return scopes.every((scope) => scope.length > 0 && !/\s/.test(scope));
},
),
.default("okta.apps.read"),
});

const TokenValidationSchema = Yup.object().shape({
orgUrl: Yup.string().required().trim().url().label("Organization URL"),
token: Yup.string()
.required()
.trim()
.matches(/^[^\s]+$/, "Cannot contain spaces")
.label("Token"),
});

const AuthenticateOktaForm = () => {
const organizationKey = useAppSelector(selectOrganizationFidesKey);
const dispatch = useAppDispatch();
const { successAlert } = useAlert();
const { flags } = useFlags();

const [scannerError, setScannerError] = useState<ParsedError>();

const useOAuth2 = flags.oktaMonitor;

const handleResults = (results: GenerateResponse["generate_results"]) => {
const systems: System[] = (results ?? []).filter(isSystem);
dispatch(setSystemsForReview(systems));
Expand All @@ -124,7 +126,7 @@ const AuthenticateOktaForm = () => {

const [generate, { isLoading }] = useGenerateMutation();

const handleSubmit = async (values: FormValues) => {
const handleOAuth2Submit = async (values: OAuth2FormValues) => {
setScannerError(undefined);

const config: OktaOAuth2Config = {
Expand All @@ -148,11 +150,130 @@ const AuthenticateOktaForm = () => {
}
};

const handleTokenSubmit = async (values: TokenFormValues) => {
setScannerError(undefined);

const config: OktaTokenConfig = {
orgUrl: values.orgUrl,
token: values.token,
};

const result = await generate({
organization_key: organizationKey,
generate: {
config: config as OktaConfig,
target: ValidTargets.OKTA,
type: GenerateTypes.SYSTEMS,
},
});

if (isErrorResult(result)) {
handleError(result.error);
} else {
handleResults(result.data.generate_results);
}
};

if (useOAuth2) {
return (
<Formik
initialValues={oauth2InitialValues}
validationSchema={OAuth2ValidationSchema}
onSubmit={handleOAuth2Submit}
>
{({ isValid, isSubmitting, dirty }) => (
<Form data-testid="authenticate-okta-form">
<Stack spacing={10}>
{isSubmitting ? (
<ScannerLoading
title="System scanning in progress"
onClose={handleCancel}
/>
) : null}

{scannerError ? <ScannerError error={scannerError} /> : null}
{!isSubmitting && !scannerError ? (
<>
<Box>
<NextBreadcrumb
className="mb-4"
items={[
{
title: "Add systems",
href: "",
onClick: (e) => {
e.preventDefault();
handleCancel();
},
},
{ title: "Authenticate Okta Scanner" },
]}
/>
<Text>
To use the scanner to inventory systems in Okta, you must
first authenticate using OAuth2 Client Credentials.
You&apos;ll need to create an API Services application in
Okta and generate an RSA key pair.
</Text>
</Box>
<Stack>
<CustomTextInput
name="orgUrl"
label="Organization URL"
tooltip="The URL for your organization's Okta account (e.g. https://your-org.okta.com)"
placeholder="https://your-org.okta.com"
/>
<CustomTextInput
name="clientId"
label="Client ID"
tooltip="The OAuth2 client ID from your Okta API Services application"
placeholder="0oa1abc2def3ghi4jkl5"
/>
<CustomTextArea
name="privateKey"
label="Private key"
tooltip="RSA private key in PEM or JWK format for OAuth2 authentication"
placeholder="-----BEGIN PRIVATE KEY-----&#10;MIIEvgIBADANBgkqhkiG9w0...&#10;-----END PRIVATE KEY-----"
textAreaProps={{
rows: 8,
style: { fontFamily: "monospace", fontSize: "12px" },
}}
/>
<CustomTextInput
name="scopes"
label="Scopes"
tooltip="OAuth2 scopes to request. Default is okta.apps.read for application discovery"
placeholder="okta.apps.read"
/>
</Stack>
</>
) : null}
{!isSubmitting ? (
<HStack>
<Button onClick={handleCancel}>Cancel</Button>
<Button
htmlType="submit"
type="primary"
disabled={!dirty || !isValid}
loading={isLoading}
data-testid="submit-btn"
>
Save and continue
</Button>
</HStack>
) : null}
</Stack>
</Form>
)}
</Formik>
);
}

return (
<Formik
initialValues={initialValues}
validationSchema={ValidationSchema}
onSubmit={handleSubmit}
initialValues={tokenInitialValues}
validationSchema={TokenValidationSchema}
onSubmit={handleTokenSubmit}
>
{({ isValid, isSubmitting, dirty }) => (
<Form data-testid="authenticate-okta-form">
Expand Down Expand Up @@ -182,36 +303,23 @@ const AuthenticateOktaForm = () => {
{ title: "Authenticate Okta Scanner" },
]}
/>
<Text>{OKTA_AUTH_DESCRIPTION}</Text>
<Text>
To use the scanner to inventory systems in Okta, you must
first authenticate to your Okta account by providing the
following information:
</Text>
</Box>
<Stack>
<CustomTextInput
name="orgUrl"
label="Organization URL"
tooltip="The URL for your organization's Okta account (e.g. https://your-org.okta.com)"
placeholder="https://your-org.okta.com"
/>
<CustomTextInput
name="clientId"
label="Client ID"
tooltip="The OAuth2 client ID from your Okta API Services application"
placeholder="0oa1abc2def3ghi4jkl5"
/>
<CustomTextArea
name="privateKey"
label="Private key"
tooltip="RSA private key in JWK format for OAuth2 authentication"
placeholder='{"kty":"RSA","kid":"...","n":"...","e":"AQAB","d":"..."}'
textAreaProps={{
rows: 8,
style: { fontFamily: "monospace", fontSize: "12px" },
}}
label="Domain"
tooltip="The URL for your organization's account on Okta"
/>
<CustomTextInput
name="scopes"
label="Scopes"
tooltip="OAuth2 scopes to request. Default is okta.apps.read for application discovery"
placeholder="okta.apps.read"
name="token"
label="Okta token"
type="password"
tooltip="The token generated by Okta for your account."
/>
</Stack>
</>
Expand Down
1 change: 0 additions & 1 deletion clients/admin-ui/src/features/connection-type/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export type ConnectionTypeSecretSchemaProperty = {
items?: { $ref: string };
sensitive?: boolean;
multiselect?: boolean;
multiline?: boolean;
options?: string[];
};

Expand Down
Loading
Loading