Skip to content

Commit

Permalink
feat: redirect users to SSO login page if required (#13116)
Browse files Browse the repository at this point in the history
## What

Closes airbytehq/airbyte-internal-issues#8376

When we get the `error:auth/sso-required` error from the get_or_create_user endpoint, log out the user silently (so the keycloak session is terminated again) and bring them to the SSO login page, with a specific state, that causes an error message to be shown.

https://github.com/user-attachments/assets/da23a0da-8d4e-4106-8d0a-08a115850b23

### Testing

I've deployed [Pedro's PR](https://github.com/airbytehq/airbyte-platform-internal/pull/13063/files) to frontend-dev at the moment, so this can be tested against it. There is email_domain block registered for `timroes.de` and a user account exists with `[email protected]` / `asdasdasdasd`.

## Can this PR be safely reverted and rolled back?
<!--
* If you know that your be safely rolled back, check YES.*
* If that is not the case (e.g. a database migration), check NO.
* If unsure, leave it blank.*
-->
- [ ] YES 💚
- [ ] NO ❌
  • Loading branch information
timroes committed Jul 17, 2024
1 parent 7bccaef commit 8f00c41
Show file tree
Hide file tree
Showing 3 changed files with 56 additions and 23 deletions.
1 change: 1 addition & 0 deletions airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1732,6 +1732,7 @@
"login.sso.backToSsoLogin": "Back to SSO login",
"login.sso.invalidCompanyIdentifier": "Invalid company identifier",
"login.sso.unknownError": "An unknown error occurred during login. Please try again.",
"login.sso.ssoLoginRequired": "You need to log in using SSO.",

"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 @@ -8,10 +8,11 @@ import { useNavigate } from "react-router-dom";

import { LoadingPage } from "components";

import { useGetOrCreateUser, useUpdateUser } from "core/api";
import { HttpProblem, useGetOrCreateUser, useUpdateUser } from "core/api";
import { UserRead } from "core/api/types/AirbyteClient";
import { config } from "core/config";
import { AuthContext, AuthContextApi } from "core/services/auth";
import { CloudRoutes } from "packages/cloud/cloudRoutePaths";

/**
* The ID of the client in Keycloak that should be used by the webapp.
Expand All @@ -26,6 +27,8 @@ const KEYCLOAK_IDP_HINT = "default";
*/
const AIRBYTE_CLOUD_REALM = "_airbyte-cloud-users";

export const SSO_LOGIN_REQUIRED_STATE = "ssoLoginRequired";

interface KeycloakAuthState {
airbyteUser: UserRead | null;
keycloakUser: User | null;
Expand Down Expand Up @@ -204,6 +207,18 @@ export const CloudAuthService: React.FC<PropsWithChildren> = ({ children }) => {
// Allows us to get the access token as a callback, instead of re-rendering every time a new access token arrives
const keycloakAccessTokenRef = useRef<string | null>(null);

const handleAirbyteUserError = useCallback(
async (error: unknown) => {
if (HttpProblem.isType(error, "error:auth/sso-required")) {
// Navigate to the SSO signin page and set the state to show an info message there
navigate(CloudRoutes.Sso, { state: { [SSO_LOGIN_REQUIRED_STATE]: true } });
return await userManager.signoutSilent();
}
throw error;
},
[navigate, userManager]
);

// Handle login/logoff that happened in another tab
useEffect(() => {
broadcastChannel.onmessage = (event) => {
Expand Down Expand Up @@ -239,13 +254,17 @@ export const CloudAuthService: React.FC<PropsWithChildren> = ({ children }) => {
clearSsoSearchParams();
// Otherwise, check if there is a session currently
} else if ((keycloakUser ??= await userManager.signinSilent())) {
// Initialize the access token ref with a value
keycloakAccessTokenRef.current = keycloakUser.access_token;
const airbyteUser = await getAirbyteUser({
authUserId: keycloakUser.profile.sub,
getAccessToken: () => Promise.resolve(keycloakUser?.access_token ?? ""),
});
dispatch({ type: "userLoaded", airbyteUser, keycloakUser });
try {
const airbyteUser = await getAirbyteUser({
authUserId: keycloakUser.profile.sub,
getAccessToken: () => Promise.resolve(keycloakUser?.access_token ?? ""),
});
// Initialize the access token ref with a value
keycloakAccessTokenRef.current = keycloakUser.access_token;
dispatch({ type: "userLoaded", airbyteUser, keycloakUser });
} catch (error) {
handleAirbyteUserError(error);
}
// Finally, we can assume there is no active session
} else {
dispatch({ type: "userUnloaded" });
Expand All @@ -254,24 +273,28 @@ export const CloudAuthService: React.FC<PropsWithChildren> = ({ children }) => {
dispatch({ type: "error", error });
}
})();
}, [userManager, getAirbyteUser]);
}, [userManager, getAirbyteUser, handleAirbyteUserError]);

// Hook in to userManager events
useEffect(() => {
const handleUserLoaded = async (keycloakUser: User) => {
const airbyteUser = await getAirbyteUser({
authUserId: keycloakUser.profile.sub,
getAccessToken: () => Promise.resolve(keycloakUser?.access_token ?? ""),
});

// Update the access token ref with the new access token. This happens each time we get a fresh token.
keycloakAccessTokenRef.current = keycloakUser.access_token;

// Only if actual user values (not just access_token) have changed, do we need to update the state and cause a re-render
if (!usersAreSame({ keycloakUser, airbyteUser }, authState)) {
dispatch({ type: "userLoaded", keycloakUser, airbyteUser });
// Notify other tabs that this tab got a new user loaded (usually meaning this tab signed in)
broadcastChannel.postMessage({ type: "userLoaded", keycloakUser, airbyteUser });
try {
const airbyteUser = await getAirbyteUser({
authUserId: keycloakUser.profile.sub,
getAccessToken: () => Promise.resolve(keycloakUser?.access_token ?? ""),
});

// Update the access token ref with the new access token. This happens each time we get a fresh token.
keycloakAccessTokenRef.current = keycloakUser.access_token;

// Only if actual user values (not just access_token) have changed, do we need to update the state and cause a re-render
if (!usersAreSame({ keycloakUser, airbyteUser }, authState)) {
dispatch({ type: "userLoaded", keycloakUser, airbyteUser });
// Notify other tabs that this tab got a new user loaded (usually meaning this tab signed in)
broadcastChannel.postMessage({ type: "userLoaded", keycloakUser, airbyteUser });
}
} catch (error: unknown) {
handleAirbyteUserError(error);
}
};
userManager.events.addUserLoaded(handleUserLoaded);
Expand Down Expand Up @@ -303,7 +326,7 @@ export const CloudAuthService: React.FC<PropsWithChildren> = ({ children }) => {
userManager.events.removeSilentRenewError(handleSilentRenewError);
userManager.events.removeAccessTokenExpired(handleExpiredToken);
};
}, [userManager, getAirbyteUser, authState]);
}, [userManager, getAirbyteUser, authState, handleAirbyteUserError]);

const changeRealmAndRedirectToSignin = useCallback(async (realm: string) => {
// This is not a security measure. The realm is publicly accessible, but we don't want users to access it via the SSO flow, because that could cause confusion.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ReactNode } from "react";
import { useFormState, useWatch } from "react-hook-form";
import { FormattedMessage, useIntl } from "react-intl";
import { useLocation } from "react-router-dom";
import * as yup from "yup";

import { Form, FormControl } from "components/forms";
Expand All @@ -11,11 +12,13 @@ import { FlexContainer, FlexItem } from "components/ui/Flex";
import { Heading } from "components/ui/Heading";
import { Icon } from "components/ui/Icon";
import { Link } from "components/ui/Link";
import { Message } from "components/ui/Message";
import { Text } from "components/ui/Text";

import { useAuthService } from "core/services/auth";
import { links } from "core/utils/links";
import { CloudRoutes } from "packages/cloud/cloudRoutePaths";
import { SSO_LOGIN_REQUIRED_STATE } from "packages/cloud/services/auth/CloudAuthService";

import styles from "./SSOIdentifierPage.module.scss";

Expand All @@ -30,6 +33,7 @@ const schema = yup.object().shape({
export const SSOIdentifierPage = () => {
const { changeRealmAndRedirectToSignin } = useAuthService();
const { formatMessage } = useIntl();
const { state } = useLocation();

if (!changeRealmAndRedirectToSignin) {
throw new Error("Rendered SSOIdentifierPage while AuthService does not provide changeRealmAndRedirectToSignin");
Expand All @@ -52,6 +56,11 @@ export const SSOIdentifierPage = () => {
<FormattedMessage id="login.sso.title" />
</Heading>
</Box>
{state?.[SSO_LOGIN_REQUIRED_STATE] && (
<Box mb="lg">
<Message type="warning" text={formatMessage({ id: "login.sso.ssoLoginRequired" })} />
</Box>
)}
<Box mb="lg">
<Text>
<FormattedMessage id="login.sso.description" />
Expand Down

0 comments on commit 8f00c41

Please sign in to comment.