Skip to content

Commit

Permalink
🪟 🎉 Cloud SSO: add frontend support for multiple keycloak realms (#8855)
Browse files Browse the repository at this point in the history
Co-authored-by: Tim Roes <[email protected]>
  • Loading branch information
josephkmh and timroes committed Sep 28, 2023
1 parent fe1cde0 commit d02374b
Show file tree
Hide file tree
Showing 12 changed files with 517 additions and 62 deletions.
13 changes: 9 additions & 4 deletions airbyte-webapp/src/components/forms/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { ReactNode, useEffect } from "react";
import { useForm, FormProvider, KeepStateOptions, DefaultValues } from "react-hook-form";
import { useForm, FormProvider, KeepStateOptions, DefaultValues, UseFormReturn } from "react-hook-form";
import { SchemaOf } from "yup";

import { FormChangeTracker } from "components/common/FormChangeTracker";
Expand All @@ -11,12 +11,17 @@ import { FormDevTools } from "./FormDevTools";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FormValues = Record<string, any>;

export type FormSubmissionHandler<T extends FormValues> = (
values: T,
methods: UseFormReturn<T>
) => Promise<void | { keepStateOptions?: KeepStateOptions; resetValues?: T }>;

interface FormProps<T extends FormValues> {
/**
* The function that will be called when the form is submitted. This function should return a promise that only resolves after the submission has been handled by the upstream service.
* The return value of this function will be used to determine which parts of the form should be reset.
*/
onSubmit?: (values: T) => Promise<void | { keepStateOptions?: KeepStateOptions; resetValues?: T }>;
onSubmit?: FormSubmissionHandler<T>;
onSuccess?: (values: T) => void;
onError?: (e: Error, values: T) => void;
schema: SchemaOf<T>;
Expand Down Expand Up @@ -61,7 +66,7 @@ export const Form = <T extends FormValues>({
return;
}

return onSubmit(values)
return onSubmit(values, methods)
.then((submissionResult) => {
onSuccess?.(values);
if (submissionResult) {
Expand All @@ -79,7 +84,7 @@ export const Form = <T extends FormValues>({
<FormProvider {...methods}>
<FormDevTools />
{trackDirtyChanges && <FormChangeTracker changed={methods.formState.isDirty} />}
<form onSubmit={methods.handleSubmit((values) => processSubmission(values))}>
<form onSubmit={methods.handleSubmit(processSubmission)}>
<fieldset disabled={disabled} className={styles.fieldset}>
{children}
</fieldset>
Expand Down
1 change: 1 addition & 0 deletions airbyte-webapp/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AirbyteWebappConfig } from "./types";

export const config: AirbyteWebappConfig = {
keycloakBaseUrl: process.env.REACT_APP_KEYCLOAK_BASE_URL || window.location.origin,
segment: {
token: process.env.REACT_APP_SEGMENT_TOKEN,
enabled: !window.TRACKING_STRATEGY || window.TRACKING_STRATEGY === "segment",
Expand Down
1 change: 1 addition & 0 deletions airbyte-webapp/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ declare global {
}

export interface AirbyteWebappConfig {
keycloakBaseUrl: string;
segment: { token?: string; enabled: boolean };
fathomSiteId?: string;
apiUrl: string;
Expand Down
4 changes: 4 additions & 0 deletions airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,10 @@
"login.sso.quickLink": "Bookmark <a>cloud.airbyte.com/sso/<b>{companyIdentifier}</b></a> for direct access to your organization's SSO login.",
"login.sso.quickLink.placeholder": "your-company-name",
"login.sso.getSsoLogin": "You want to enable Single Sign-On in Airbyte? <a>Talk to us!</a>",
"login.sso.companyIdentifierNotFound": "Company identifier <b>{companyIdentifier}</b> not found.",
"login.sso.companyIdentifierFound": "Company identifier <b>{companyIdentifier}</b> found. Redirecting you to your SSO login...",
"login.sso.backToSsoLogin": "Back to SSO login",
"login.sso.invalidCompanyIdentifier": "Invalid company identifier",

"signup.details.noCreditCard": "No credit card required",
"signup.details.instantSetup": "Instant setup",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { MissingConfigError, useConfig } from "config";
import { RequestMiddleware } from "core/request/RequestMiddleware";
import { RequestAuthMiddleware } from "core/services/auth";
import { ServicesProvider, useGetService, useInjectServices } from "core/servicesProvider";
import { useLocalStorage } from "core/utils/useLocalStorage";
import { useAuth } from "packages/firebaseReact";

import { KeycloakService } from "./auth/KeycloakService";
import { FirebaseSdkProvider } from "./FirebaseSdkProvider";

/**
Expand All @@ -16,10 +18,19 @@ import { FirebaseSdkProvider } from "./FirebaseSdkProvider";
* and also adds all overrides of hooks/services
*/
const AppServicesProvider: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
// The keycloak service is behind a flag until it's ready to test on cloud
const [showSsoLogin] = useLocalStorage("airbyte_show-sso-login", false);

return (
<ServicesProvider>
<FirebaseSdkProvider>
<ServiceOverrides>{children}</ServiceOverrides>
{showSsoLogin ? (
<KeycloakService>
<ServiceOverrides>{children}</ServiceOverrides>
</KeycloakService>
) : (
<ServiceOverrides>{children}</ServiceOverrides>
)}
</FirebaseSdkProvider>
</ServicesProvider>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { renderHook, waitFor } from "@testing-library/react";
import { PropsWithChildren } from "react";

import { KeycloakService, initializeUserManager, useKeycloakService } from "./KeycloakService";

let windowSearchSpy: jest.SpyInstance;

describe(`${initializeUserManager.name}()`, () => {
beforeEach(() => {
windowSearchSpy = jest.spyOn(window, "location", "get");
});

afterEach(() => {
windowSearchSpy.mockRestore();
});

it("should initialize with the correct default realm", () => {
const userManager = initializeUserManager();
expect(userManager.settings.authority).toMatch(/auth\/realms\/airbyte/);
});

it("should initialize realm from query params", () => {
windowSearchSpy.mockImplementation(() => ({
search: "?realm=another-realm",
}));
const userManager = initializeUserManager();
expect(userManager.settings.authority).toMatch(/auth\/realms\/another-realm/);
});

it("should initialize realm based on local storage", () => {
const mockKey = "oidc.user:https://example.com/auth/realms/local-storage-realm:local-storage-client-id";
window.localStorage.setItem(mockKey, "no need to populate the value for this test");
const userManager = initializeUserManager();
expect(userManager.settings.authority).toMatch(/auth\/realms\/local-storage-realm/);
window.localStorage.removeItem(mockKey);
});
});

describe(`${KeycloakService.name}`, () => {
const wrapper: React.FC<PropsWithChildren> = ({ children }) => <KeycloakService>{children}</KeycloakService>;

it("should initialize with the correct default realm", async () => {
const { result } = renderHook(() => useKeycloakService(), { wrapper });
await waitFor(() => {
expect(result?.current.userManager.settings.authority).toMatch(/auth\/realms\/airbyte/);
});
});

it("should initialize realm from query params", async () => {
windowSearchSpy = jest.spyOn(window, "location", "get");
windowSearchSpy.mockImplementation(() => ({
search: "?realm=another-realm",
}));

const { result } = renderHook(() => useKeycloakService(), { wrapper });

await waitFor(() => {
expect(result?.current.userManager.settings.authority).toMatch(/auth\/realms\/another-realm/);
});

windowSearchSpy.mockRestore();
});

it("should initialize realm based on local storage", async () => {
window.localStorage.setItem(
"oidc.user:https://example.com/auth/realms/local-storage-realm:local-storage-client-id",
JSON.stringify({
id_token:
"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIxOXpGOG5CVWNJcDNPMTBTQVZvUE1oYnhocThsbmVnRnJaNXBZcEtzT3NzIn0.eyJleHAiOjE2OTU2ODIzMjIsImlhdCI6MTY5NTY4MjAyMiwiYXV0aF90aW1lIjoxNjk1NjgyMDIxLCJqdGkiOiIxMGQ4Y2FkNi1jMWViLTQ3MTUtODFiMS01OGZmYjU3MDgyZGEiLCJpc3MiOiJodHRwczovL2Rldi0yLWNsb3VkLmFpcmJ5dGUuY29tL2F1dGgvcmVhbG1zL3Rlc3QtY29tcGFueS0xIiwiYXVkIjoiYWlyYnl0ZS13ZWJhcHAiLCJzdWIiOiIwYWEzYmVhYi02MDU3LTQ4NjEtYWQ5MS0wNWZlM2U3NmJhMjIiLCJ0eXAiOiJJRCIsImF6cCI6ImFpcmJ5dGUtd2ViYXBwIiwic2Vzc2lvbl9zdGF0ZSI6ImQwZDBlN2FhLWE1YzgtNDgwNi05MzA4LTQ2ZWE5ODRkZmE5ZCIsImF0X2hhc2giOiJ3TjNLSFltcDRDUUs4emN0QkwyMmVnIiwiYWNyIjoiMSIsInNpZCI6ImQwZDBlN2FhLWE1YzgtNDgwNi05MzA4LTQ2ZWE5ODRkZmE5ZCIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiSm9leSBNYXJzaG1lbnQtSG93ZWxsIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWRtaW4tY29tcGFueS0xIiwiZ2l2ZW5fbmFtZSI6IkpvZXkiLCJmYW1pbHlfbmFtZSI6Ik1hcnNobWVudC1Ib3dlbGwiLCJlbWFpbCI6Impvc2VwaCthZG1pbi1jb21wYW55LTFAYWlyYnl0ZS5pbyJ9.eVbTYihQPkwBZugWClsbE6ePayDh5b3wGXYrBAhgxq0Bxe9ZdaJb_3EadqMu2xCCnYam1JgJyvnNoEBVQJlLPoBehByyBMC4xwoaHbNjvwAWHUXPlIvLIct_jo9Mnk-l2l_6uZf5rPAqBlQHQFf5SFIF_l9m7WyFafLQFemnkfy0AzmdO_yaT0LyuCpALHmXUeJuUgILuBM3AQd6IVeAi7zKwRHTk4YjaUbE4fdtFC1x11XGEVfuYJaH3S8-Zyu45vO7MKeK9gsqF6mtkgu66a0FBp8kjy4SsVCyBjoj9IZp_Q428Uy9MYX9JvQL3xEXBilv_ydjnCPp2J1pJ4Gdlw",
session_state: "d0d0e7aa-a5c8-4806-9308-46ea984dfa9d",
access_token:
"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIxOXpGOG5CVWNJcDNPMTBTQVZvUE1oYnhocThsbmVnRnJaNXBZcEtzT3NzIn0.eyJleHAiOjE2OTU2ODIzMjIsImlhdCI6MTY5NTY4MjAyMiwiYXV0aF90aW1lIjoxNjk1NjgyMDIxLCJqdGkiOiJhNTkyZDUyNi0wOTA0LTQ2NjgtOTY2OS1mNGU1ZDI5MGQ4NDQiLCJpc3MiOiJodHRwczovL2Rldi0yLWNsb3VkLmFpcmJ5dGUuY29tL2F1dGgvcmVhbG1zL3Rlc3QtY29tcGFueS0xIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjBhYTNiZWFiLTYwNTctNDg2MS1hZDkxLTA1ZmUzZTc2YmEyMiIsInR5cCI6IkJlYXJlciIsImF6cCI6ImFpcmJ5dGUtd2ViYXBwIiwic2Vzc2lvbl9zdGF0ZSI6ImQwZDBlN2FhLWE1YzgtNDgwNi05MzA4LTQ2ZWE5ODRkZmE5ZCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9sb2NhbGhvc3Q6MzAwMSIsImh0dHBzOi8vZGV2LTItY2xvdWQuYWlyYnl0ZS5jb20iXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtdGVzdC1jb21wYW55LTEiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsInNpZCI6ImQwZDBlN2FhLWE1YzgtNDgwNi05MzA4LTQ2ZWE5ODRkZmE5ZCIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiSm9leSBNYXJzaG1lbnQtSG93ZWxsIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWRtaW4tY29tcGFueS0xIiwiZ2l2ZW5fbmFtZSI6IkpvZXkiLCJmYW1pbHlfbmFtZSI6Ik1hcnNobWVudC1Ib3dlbGwiLCJlbWFpbCI6Impvc2VwaCthZG1pbi1jb21wYW55LTFAYWlyYnl0ZS5pbyJ9.3OWOx6MqFxETHlbNdzPIggIbTas0eZEojKDWfys_tqokOiLPJEn1t3Vw-YNwNP6Dy8XOtzCW1qTjRU5IhXT2bRMWVJOED3esHg2TBiJFbFOpk27ab-AZ37_h4sFnBCG0AgT1TK_JfdFiIsbXKKiLDPtJcb-EU526gu_CNYSVf0bWE8qhuynvJi6Lsw2PNcoppHX2p-PMBfLR2YillOCIDLL7vuB5X_K_NsVBZFIdHOw5i2qYYM6BI7HcjK7e2VI8TXY9h0OjoJfwmEXteDmrG2S0zmv8glAHLh8ospApZeIBr8Vt0Yek59FHgG1ocqCa4bm7fDWefprMoufitPrYSg",
refresh_token:
"eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI4NDUzZDBhOC02NzQ2LTRmMDktOGM2Ny02YjhiNmVhZTZjN2IifQ.eyJleHAiOjE2OTU2ODM4MjIsImlhdCI6MTY5NTY4MjAyMiwianRpIjoiN2MxYTkxOWQtZjNlZi00ZTRhLWFlMDUtMWM3MWIyNmE4Y2Q3IiwiaXNzIjoiaHR0cHM6Ly9kZXYtMi1jbG91ZC5haXJieXRlLmNvbS9hdXRoL3JlYWxtcy90ZXN0LWNvbXBhbnktMSIsImF1ZCI6Imh0dHBzOi8vZGV2LTItY2xvdWQuYWlyYnl0ZS5jb20vYXV0aC9yZWFsbXMvdGVzdC1jb21wYW55LTEiLCJzdWIiOiIwYWEzYmVhYi02MDU3LTQ4NjEtYWQ5MS0wNWZlM2U3NmJhMjIiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoiYWlyYnl0ZS13ZWJhcHAiLCJzZXNzaW9uX3N0YXRlIjoiZDBkMGU3YWEtYTVjOC00ODA2LTkzMDgtNDZlYTk4NGRmYTlkIiwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsInNpZCI6ImQwZDBlN2FhLWE1YzgtNDgwNi05MzA4LTQ2ZWE5ODRkZmE5ZCJ9.WRqTUNC6eY00zkYbK3GGy-wbvON47rWJu6TgXGG1QvA",
token_type: "Bearer",
scope: "openid email profile",
profile: {
exp: 1695682322,
iat: 1695682022,
iss: "https://dev-2-cloud.airbyte.com/auth/realms/test-company-1",
aud: "airbyte-webapp",
sub: "0aa3beab-6057-4861-ad91-05fe3e76ba22",
typ: "ID",
session_state: "d0d0e7aa-a5c8-4806-9308-46ea984dfa9d",
sid: "d0d0e7aa-a5c8-4806-9308-46ea984dfa9d",
email_verified: true,
name: "Joey Marshment-Howell",
preferred_username: "admin-company-1",
given_name: "Joey",
family_name: "Marshment-Howell",
email: "[email protected]",
},
expires_at: 1695682322,
})
);

const { result } = renderHook(() => useKeycloakService(), { wrapper });

await waitFor(() => {
expect(result?.current.userManager.settings.authority).toMatch(/auth\/realms\/local-storage-realm/);
});
});
});
Loading

0 comments on commit d02374b

Please sign in to comment.