From d02374b84dfacac2a06814e9d1155f0df5672f5c Mon Sep 17 00:00:00 2001 From: Joey Marshment-Howell Date: Thu, 28 Sep 2023 09:54:52 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=9F=20=F0=9F=8E=89=20Cloud=20SSO:=20ad?= =?UTF-8?q?d=20frontend=20support=20for=20multiple=20keycloak=20realms=20(?= =?UTF-8?q?#8855)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tim Roes --- airbyte-webapp/src/components/forms/Form.tsx | 13 +- airbyte-webapp/src/config/config.ts | 1 + airbyte-webapp/src/config/types.ts | 1 + airbyte-webapp/src/locales/en.json | 4 + .../cloud/services/AppServicesProvider.tsx | 13 +- .../KeycloakService/KeycloakService.test.tsx | 103 ++++++++ .../auth/KeycloakService/KeycloakService.tsx | 250 ++++++++++++++++++ .../services/auth/KeycloakService/index.ts | 1 + .../views/auth/OAuthLogin/OAuthLogin.tsx | 10 +- .../auth/SSOBookmarkPage/SSOBookmark.tsx | 50 +++- .../SSOBookmarkPage.module.scss | 5 + .../SSOIdentifierPage/SSOIdentifierPage.tsx | 128 +++++---- 12 files changed, 517 insertions(+), 62 deletions(-) create mode 100644 airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/KeycloakService.test.tsx create mode 100644 airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/KeycloakService.tsx create mode 100644 airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/index.ts create mode 100644 airbyte-webapp/src/packages/cloud/views/auth/SSOBookmarkPage/SSOBookmarkPage.module.scss diff --git a/airbyte-webapp/src/components/forms/Form.tsx b/airbyte-webapp/src/components/forms/Form.tsx index 394005bbd8d..c18ce8e1611 100644 --- a/airbyte-webapp/src/components/forms/Form.tsx +++ b/airbyte-webapp/src/components/forms/Form.tsx @@ -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"; @@ -11,12 +11,17 @@ import { FormDevTools } from "./FormDevTools"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type FormValues = Record; +export type FormSubmissionHandler = ( + values: T, + methods: UseFormReturn +) => Promise; + interface FormProps { /** * 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; + onSubmit?: FormSubmissionHandler; onSuccess?: (values: T) => void; onError?: (e: Error, values: T) => void; schema: SchemaOf; @@ -61,7 +66,7 @@ export const Form = ({ return; } - return onSubmit(values) + return onSubmit(values, methods) .then((submissionResult) => { onSuccess?.(values); if (submissionResult) { @@ -79,7 +84,7 @@ export const Form = ({ {trackDirtyChanges && } -
processSubmission(values))}> +
{children}
diff --git a/airbyte-webapp/src/config/config.ts b/airbyte-webapp/src/config/config.ts index 8a4e4d78e2e..aa3256f904b 100644 --- a/airbyte-webapp/src/config/config.ts +++ b/airbyte-webapp/src/config/config.ts @@ -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", diff --git a/airbyte-webapp/src/config/types.ts b/airbyte-webapp/src/config/types.ts index a4d9548a9b4..26b8b26c489 100644 --- a/airbyte-webapp/src/config/types.ts +++ b/airbyte-webapp/src/config/types.ts @@ -5,6 +5,7 @@ declare global { } export interface AirbyteWebappConfig { + keycloakBaseUrl: string; segment: { token?: string; enabled: boolean }; fathomSiteId?: string; apiUrl: string; diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index ba153b17477..723d1ed5261 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -1204,6 +1204,10 @@ "login.sso.quickLink": "Bookmark cloud.airbyte.com/sso/{companyIdentifier} 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? Talk to us!", + "login.sso.companyIdentifierNotFound": "Company identifier {companyIdentifier} not found.", + "login.sso.companyIdentifierFound": "Company identifier {companyIdentifier} 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", diff --git a/airbyte-webapp/src/packages/cloud/services/AppServicesProvider.tsx b/airbyte-webapp/src/packages/cloud/services/AppServicesProvider.tsx index 087642943c5..c5233be322a 100644 --- a/airbyte-webapp/src/packages/cloud/services/AppServicesProvider.tsx +++ b/airbyte-webapp/src/packages/cloud/services/AppServicesProvider.tsx @@ -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"; /** @@ -16,10 +18,19 @@ import { FirebaseSdkProvider } from "./FirebaseSdkProvider"; * and also adds all overrides of hooks/services */ const AppServicesProvider: React.FC> = ({ 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 ( - {children} + {showSsoLogin ? ( + + {children} + + ) : ( + {children} + )} ); diff --git a/airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/KeycloakService.test.tsx b/airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/KeycloakService.test.tsx new file mode 100644 index 00000000000..d50089340ba --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/KeycloakService.test.tsx @@ -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 = ({ children }) => {children}; + + 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: "joseph+admin-company-1@airbyte.io", + }, + expires_at: 1695682322, + }) + ); + + const { result } = renderHook(() => useKeycloakService(), { wrapper }); + + await waitFor(() => { + expect(result?.current.userManager.settings.authority).toMatch(/auth\/realms\/local-storage-realm/); + }); + }); +}); diff --git a/airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/KeycloakService.tsx b/airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/KeycloakService.tsx new file mode 100644 index 00000000000..64a65545f1d --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/KeycloakService.tsx @@ -0,0 +1,250 @@ +import { User, WebStorageStateStore } from "oidc-client-ts"; +import { UserManager } from "oidc-client-ts"; +import { + PropsWithChildren, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; + +import { config } from "config"; + +export const DEFAULT_KEYCLOAK_REALM = "airbyte"; +export const DEFAULT_KEYCLOAK_CLIENT_ID = "airbyte-webapp"; + +interface KeycloakRealmContextType { + userManager: UserManager; + signinRedirect: () => Promise; + signoutRedirect: () => Promise; + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + error: Error | null; + changeRealmAndRedirectToSignin: (realm: string) => Promise; +} + +const keycloakServiceContext = createContext(undefined); + +export const useKeycloakService = () => { + const context = useContext(keycloakServiceContext); + + if (context === undefined) { + throw new Error(`${useKeycloakService.name} must be used within a KeycloakRealmContext`); + } + + return context; +}; + +interface KeycloakAuthState { + user: User | null; + error: Error | null; + isInitializing: boolean; + isAuthenticated: boolean; +} + +const keycloakAuthStateInitialState: KeycloakAuthState = { + user: null, + error: null, + isInitializing: true, + isAuthenticated: false, +}; + +type KeycloakAuthStateAction = + | { + type: "userLoaded"; + user: User; + } + | { + type: "userUnloaded"; + } + | { + type: "error"; + error: Error; + }; + +const keycloakAuthStateReducer = (state: KeycloakAuthState, action: KeycloakAuthStateAction): KeycloakAuthState => { + switch (action.type) { + case "userLoaded": + return { + ...state, + user: action.user, + isAuthenticated: true, + isInitializing: false, + error: null, + }; + case "userUnloaded": + return { + ...state, + user: null, + isAuthenticated: false, + isInitializing: false, + error: null, + }; + case "error": + return { + ...state, + isInitializing: false, + error: action.error, + }; + } +}; + +export const KeycloakService: React.FC = ({ children }) => { + const userSigninInitialized = useRef(false); + const [userManager] = useState(initializeUserManager); + const [authState, dispatch] = useReducer(keycloakAuthStateReducer, keycloakAuthStateInitialState); + + // Initialization of the current user + useEffect(() => { + if (!userManager || userSigninInitialized.current) { + return; + } + // We strictly need to initialize once, because authorization codes are only valid for a single use + userSigninInitialized.current = true; + + (async (): Promise => { + let user: User | void | null = null; + try { + // check if returning back from authority server + if (hasAuthParams()) { + user = await userManager.signinCallback(); + clearSsoSearchParams(); + } + // If not returning from authority server, check if we can get a user + if ((user ??= await userManager.getUser())) { + dispatch({ type: "userLoaded", user }); + } else { + dispatch({ type: "userUnloaded" }); + } + } catch (error) { + dispatch({ type: "error", error }); + } + })(); + }, [userManager]); + + // Hook in to userManager events + useEffect(() => { + if (!userManager) { + return undefined; + } + + const handleUserLoaded = (user: User) => { + dispatch({ type: "userLoaded", user }); + }; + userManager.events.addUserLoaded(handleUserLoaded); + + const handleUserUnloaded = () => { + dispatch({ type: "userUnloaded" }); + }; + userManager.events.addUserUnloaded(handleUserUnloaded); + + const handleSilentRenewError = (error: Error) => { + dispatch({ type: "error", error }); + }; + userManager.events.addSilentRenewError(handleSilentRenewError); + + const handleExpiredToken = () => { + userManager.signinSilent().catch(async () => { + // We need to manually sign out, otherwise the expired token and user will stick around + await userManager.signoutSilent(); + dispatch({ type: "userUnloaded" }); + }); + }; + userManager.events.addAccessTokenExpired(handleExpiredToken); + + return () => { + userManager.events.removeUserLoaded(handleUserLoaded); + userManager.events.removeUserUnloaded(handleUserUnloaded); + userManager.events.removeSilentRenewError(handleSilentRenewError); + userManager.events.removeAccessTokenExpired(handleExpiredToken); + }; + }, [userManager]); + + const changeRealmAndRedirectToSignin = useCallback(async (realm: string) => { + const newUserManager = createUserManager(realm); + await newUserManager.signinRedirect(); + }, []); + + const contextValue = useMemo(() => { + return { + ...authState, + userManager, + signinRedirect: () => userManager.signinRedirect(), + signoutRedirect: () => userManager.signoutRedirect(), + isAuthenticated: userManager.getUser() !== null, + isLoading: false, + error: null, + changeRealmAndRedirectToSignin, + }; + }, [userManager, changeRealmAndRedirectToSignin, authState]); + + return {children}; +}; + +function createUserManager(realm: string) { + const searchParams = new URLSearchParams(window.location.search); + searchParams.set("realm", realm); + const redirect_uri = `${window.location.origin}?${searchParams.toString()}`; + const userManager = new UserManager({ + userStore: new WebStorageStateStore({ store: window.localStorage }), + authority: `${config.keycloakBaseUrl}/auth/realms/${realm}`, + client_id: DEFAULT_KEYCLOAK_CLIENT_ID, + redirect_uri, + }); + return userManager; +} + +export function initializeUserManager() { + // First, check if there's an active redirect in progress. If so, we can pull the realm & clientId from the query params + const searchParams = new URLSearchParams(window.location.search); + const realm = searchParams.get("realm"); + if (realm) { + return createUserManager(realm); + } + + // If there's no active redirect, so we can check for an existing session based on an entry in local storage + // The local storage key looks like this: oidc.user:https://example.com/auth/realms/: + const localStorageKeys = Object.keys(window.localStorage); + const realmAndClientId = localStorageKeys.find((key) => key.startsWith("oidc.user:")); + if (realmAndClientId) { + const match = realmAndClientId.match(/^oidc.user:.*\/(?[^:]+):(?.+)$/); + if (match?.groups) { + return createUserManager(match.groups.realm); + } + } + + // If no session is found, we can fall back to the default realm and client id + return createUserManager(DEFAULT_KEYCLOAK_REALM); +} + +function clearSsoSearchParams() { + const searchParams = new URLSearchParams(window.location.search); + + // Remove OIDC params from URL, but don't remove other params that might be present + searchParams.delete("state"); + searchParams.delete("code"); + searchParams.delete("session_state"); + + // Remove our own params we set in the redirect_uri + searchParams.delete("realm"); + + const newUrl = searchParams.toString().length + ? `${window.location.pathname}?${searchParams.toString()}` + : window.location.pathname; + window.history.replaceState({}, document.title, newUrl); +} + +export const hasAuthParams = (location = window.location): boolean => { + // response_mode: query + const searchParams = new URLSearchParams(location.search); + if ((searchParams.get("code") || searchParams.get("error")) && searchParams.get("state")) { + return true; + } + + return false; +}; diff --git a/airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/index.ts b/airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/index.ts new file mode 100644 index 00000000000..50f53eaf69d --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/services/auth/KeycloakService/index.ts @@ -0,0 +1 @@ +export * from "./KeycloakService"; diff --git a/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.tsx b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.tsx index 96d29a04f64..f1465066e76 100644 --- a/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.tsx +++ b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.tsx @@ -2,7 +2,7 @@ import { faIdCardClip } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRef, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { createSearchParams, useNavigate, useSearchParams } from "react-router-dom"; import { useUnmount } from "react-use"; import { Subscription } from "rxjs"; @@ -37,8 +37,14 @@ const GoogleButton: React.FC<{ onClick: () => void }> = ({ onClick }) => { }; const SsoButton: React.FC = () => { + const [searchParams] = useSearchParams(); + const loginRedirectString = searchParams.get("loginRedirect"); + const linkLocation = loginRedirectString + ? { pathname: CloudRoutes.Sso, search: createSearchParams({ loginRedirect: loginRedirectString }).toString() } + : CloudRoutes.Sso; + return ( - + diff --git a/airbyte-webapp/src/packages/cloud/views/auth/SSOBookmarkPage/SSOBookmark.tsx b/airbyte-webapp/src/packages/cloud/views/auth/SSOBookmarkPage/SSOBookmark.tsx index 461b23da25f..94e7f30eb6c 100644 --- a/airbyte-webapp/src/packages/cloud/views/auth/SSOBookmarkPage/SSOBookmark.tsx +++ b/airbyte-webapp/src/packages/cloud/views/auth/SSOBookmarkPage/SSOBookmark.tsx @@ -1,27 +1,65 @@ +import { useCallback, useEffect, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; import { Navigate, useParams } from "react-router-dom"; import { FlexContainer } from "components/ui/Flex"; import { Link } from "components/ui/Link"; +import { LoadingSpinner } from "components/ui/LoadingSpinner"; +import { Message } from "components/ui/Message"; import { Text } from "components/ui/Text"; import { CloudRoutes } from "packages/cloud/cloudRoutePaths"; +import { useKeycloakService } from "packages/cloud/services/auth/KeycloakService"; + +import styles from "./SSOBookmarkPage.module.scss"; // A bookmarkable route that redirects to the SSO login page with the provided company identifier export const SSOBookmarkPage = () => { + const { changeRealmAndRedirectToSignin } = useKeycloakService(); const { companyIdentifier } = useParams(); + const [state, setState] = useState<"loading" | "error">("loading"); + const { formatMessage } = useIntl(); + + const validateCompanyIdentifier = useCallback( + async (companyIdentifier: string) => { + try { + return await changeRealmAndRedirectToSignin(companyIdentifier); + } catch (e) { + setState("error"); + return Promise.reject(formatMessage({ id: "login.sso.invalidCompanyIdentifier" })); + } + }, + [changeRealmAndRedirectToSignin, formatMessage] + ); + + useEffect(() => { + if (!companyIdentifier) { + return; + } + + validateCompanyIdentifier(companyIdentifier); + }, [validateCompanyIdentifier, companyIdentifier]); if (!companyIdentifier) { return ; } - // TODO: configure this realm in react-oidc-context and redirect to keycloak. For now, display a harmless error message in case someone stumbles across this. + if (state === "loading") { + return ; + } + return ( - - - Company identifier {companyIdentifier} not found. - + + + } + /> + - Back to SSO login + + + ); diff --git a/airbyte-webapp/src/packages/cloud/views/auth/SSOBookmarkPage/SSOBookmarkPage.module.scss b/airbyte-webapp/src/packages/cloud/views/auth/SSOBookmarkPage/SSOBookmarkPage.module.scss new file mode 100644 index 00000000000..15d6e851284 --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/auth/SSOBookmarkPage/SSOBookmarkPage.module.scss @@ -0,0 +1,5 @@ +@use "scss/variables"; + +.ssoBookmarkPage { + width: variables.$width-login-form; +} diff --git a/airbyte-webapp/src/packages/cloud/views/auth/SSOIdentifierPage/SSOIdentifierPage.tsx b/airbyte-webapp/src/packages/cloud/views/auth/SSOIdentifierPage/SSOIdentifierPage.tsx index d061a0059f9..ddb3ce354ea 100644 --- a/airbyte-webapp/src/packages/cloud/views/auth/SSOIdentifierPage/SSOIdentifierPage.tsx +++ b/airbyte-webapp/src/packages/cloud/views/auth/SSOIdentifierPage/SSOIdentifierPage.tsx @@ -1,21 +1,23 @@ import { faComments, faLightbulb } from "@fortawesome/free-regular-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ReactNode } from "react"; -import { useWatch } from "react-hook-form"; +import { useFormState, useWatch } from "react-hook-form"; import { FormattedMessage, useIntl } from "react-intl"; -import { useNavigate } from "react-router-dom"; import * as yup from "yup"; import { Form, FormControl } from "components/forms"; +import { FormSubmissionHandler } from "components/forms/Form"; import { Box } from "components/ui/Box"; import { Button } from "components/ui/Button"; import { FlexContainer, FlexItem } from "components/ui/Flex"; import { Heading } from "components/ui/Heading"; import { Link } from "components/ui/Link"; +import { Message } from "components/ui/Message"; import { Text } from "components/ui/Text"; import { links } from "core/utils/links"; import { CloudRoutes } from "packages/cloud/cloudRoutePaths"; +import { useKeycloakService } from "packages/cloud/services/auth/KeycloakService"; import styles from "./SSOIdentifierPage.module.scss"; @@ -28,60 +30,88 @@ const schema = yup.object().shape({ }); export const SSOIdentifierPage = () => { + const { changeRealmAndRedirectToSignin, user, signoutRedirect } = useKeycloakService(); const { formatMessage } = useIntl(); - const navigate = useNavigate(); - const handleSubmit = (values: CompanyIdentifierValues) => { - // TODO: instead of navigating, we could register the realm here with react-oidc-context and then redirect directly to keycloak - navigate(`${CloudRoutes.Sso}/${values.companyIdentifier}`); - return Promise.resolve(); + const handleSubmit: FormSubmissionHandler = async ({ companyIdentifier }, methods) => { + try { + return await changeRealmAndRedirectToSignin(companyIdentifier); + } catch (e) { + methods.setError("companyIdentifier", { message: "login.sso.invalidCompanyIdentifier" }); + return Promise.reject(); + } }; return ( -
- onSubmit={handleSubmit} defaultValues={{ companyIdentifier: "" }} schema={schema}> - - - - - - - - - - - - fieldType="input" - name="companyIdentifier" - placeholder={formatMessage({ id: "login.sso.companyIdentifier.placeholder" })} - label={formatMessage({ id: "login.sso.companyIdentifier.label" })} - /> - - - - + <> +
+ + onSubmit={handleSubmit} + defaultValues={{ companyIdentifier: "" }} + schema={schema} + > + + + + + + + + - - - - - + + + fieldType="input" + name="companyIdentifier" + placeholder={formatMessage({ id: "login.sso.companyIdentifier.placeholder" })} + label={formatMessage({ id: "login.sso.companyIdentifier.label" })} + /> + + + + + + + + + + {content} }} + /> + + + + {/* Temporary way to log out, since we cannot create users and show the full UI yet */} +
+ {user && ( + + } + onAction={() => signoutRedirect()} + /> - - - - - - {content} }} - /> - - - -
+ )} + + ); +}; + +const FormSubmissionButton = () => { + const { isSubmitting } = useFormState(); + + return ( + + + + + + + + ); };