diff --git a/packages/manager/src/OAuth/OAuthCallback.tsx b/packages/manager/src/OAuth/OAuthCallback.tsx index 454bea3f446..28a3ff2ea49 100644 --- a/packages/manager/src/OAuth/OAuthCallback.tsx +++ b/packages/manager/src/OAuth/OAuthCallback.tsx @@ -1,11 +1,14 @@ import * as Sentry from '@sentry/react'; import { useNavigate } from '@tanstack/react-router'; +import { useSearch } from '@tanstack/react-router'; import React from 'react'; import { SplashScreen } from 'src/components/SplashScreen'; import { clearStorageAndRedirectToLogout, handleOAuthCallback } from './oauth'; +import type { LinkProps } from '@tanstack/react-router'; + /** * Login will redirect back to Cloud Manager with a URL like: * https://cloud.linode.com/oauth/callback?returnTo=%2F&state=066a6ad9-b19a-43bb-b99a-ef0b5d4fc58d&code=42ddf75dfa2cacbad897 @@ -14,24 +17,57 @@ import { clearStorageAndRedirectToLogout, handleOAuthCallback } from './oauth'; */ export const OAuthCallback = () => { const navigate = useNavigate(); - const authenticate = async () => { - try { - const { returnTo } = await handleOAuthCallback({ - params: location.search, - }); - - navigate({ to: returnTo }); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - Sentry.captureException(error); - clearStorageAndRedirectToLogout(); - } - }; + const search = useSearch({ + from: '/oauth/callback', + }); + + const hasStartedAuth = React.useRef(false); + const isAuthenticating = React.useRef(false); React.useEffect(() => { + // Prevent running if already started or currently running + if (hasStartedAuth.current || isAuthenticating.current) { + return; + } + + hasStartedAuth.current = true; + isAuthenticating.current = true; + + const authenticate = async () => { + try { + const { returnTo } = await handleOAuthCallback({ + params: search, + }); + + // None of these paths are valid return destinations + const invalidReturnToPaths: LinkProps['to'][] = [ + '/logout', + '/admin/callback', + '/oauth/callback', + '/cancel', + ]; + + const isInvalidReturnTo = + !returnTo || invalidReturnToPaths.some((path) => returnTo === path); + + if (isInvalidReturnTo) { + navigate({ to: '/' }); + return; + } + + navigate({ to: returnTo }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + Sentry.captureException(error); + clearStorageAndRedirectToLogout(); + } finally { + isAuthenticating.current = false; + } + }; + authenticate(); - }, []); + }, [navigate, search]); return ; }; diff --git a/packages/manager/src/OAuth/oauth.ts b/packages/manager/src/OAuth/oauth.ts index 85b08c4618b..3d111dbecbe 100644 --- a/packages/manager/src/OAuth/oauth.ts +++ b/packages/manager/src/OAuth/oauth.ts @@ -187,10 +187,13 @@ export async function redirectToLogin() { * @returns Some information about the new session because authentication was successfull */ export async function handleOAuthCallback(options: AuthCallbackOptions) { + const paramsObject = + typeof options.params === 'string' + ? getQueryParamsFromQueryString(options.params) + : options.params; + const { data: params, error: parseParamsError } = await tryCatch( - OAuthCallbackParamsSchema.validate( - getQueryParamsFromQueryString(options.params) - ) + OAuthCallbackParamsSchema.validate(paramsObject) ); if (parseParamsError) { @@ -296,7 +299,9 @@ export async function handleLoginAsCustomerCallback( ) { const { data: params, error } = await tryCatch( LoginAsCustomerCallbackParamsSchema.validate( - getQueryParamsFromQueryString(options.params) + typeof options.params === 'string' + ? getQueryParamsFromQueryString(options.params) + : options.params ) ); diff --git a/packages/manager/src/OAuth/types.ts b/packages/manager/src/OAuth/types.ts index 6dd3e5d14a4..e0b5b0e3fe1 100644 --- a/packages/manager/src/OAuth/types.ts +++ b/packages/manager/src/OAuth/types.ts @@ -61,7 +61,7 @@ export interface AuthCallbackOptions { /** * The raw search or has params sent by the login server */ - params: string; + params: Record | string; } /** diff --git a/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts b/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts index 30024a756b7..8d5fc172f1e 100644 --- a/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts +++ b/packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts @@ -50,12 +50,12 @@ export const checkIAMEnabled = async ( flags: FlagSet, profile: Profile | undefined ): Promise => { - if (!flags?.iam?.enabled) { + if (!flags?.iam?.enabled || !profile) { return false; } try { - if (profile?.username) { + if (profile.username) { // For restricted users ONLY, get permissions const permissions = await queryClient.ensureQueryData( queryOptions(iamQueries.user(profile.username)._ctx.accountPermissions) diff --git a/packages/manager/src/routes/auth/index.ts b/packages/manager/src/routes/auth/index.ts index 8b91f930a0d..43ad01f2c6f 100644 --- a/packages/manager/src/routes/auth/index.ts +++ b/packages/manager/src/routes/auth/index.ts @@ -7,6 +7,12 @@ import { OAuthCallback } from 'src/OAuth/OAuthCallback'; import { rootRoute } from '../root'; +interface OAuthCallbackSearch { + code?: string; + returnTo?: string; + state?: string; +} + interface CancelLandingSearch { survey_link?: string; } @@ -34,6 +40,7 @@ const oauthCallbackRoute = createRoute({ getParentRoute: () => rootRoute, path: 'oauth/callback', component: OAuthCallback, + validateSearch: (search: OAuthCallbackSearch) => search, }); export {