Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Web MFA dialog for admin actions #50373

Open
wants to merge 16 commits into
base: joerger/fix-useMfa-error-state
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { act, fireEvent, render, screen } from 'design/utils/testing';
import { act, fireEvent, render, screen, waitFor } from 'design/utils/testing';

import cfg from 'teleport/config';
import { ComponentWrapper } from 'teleport/Discover/Fixtures/kubernetes';
Expand Down Expand Up @@ -179,6 +179,7 @@ describe('test EnrollEksCluster.tsx', () => {

expect(integrationService.enrollEksClusters).not.toHaveBeenCalled();
});

test('auto enroll disabled, enrolls cluster', async () => {
jest.spyOn(integrationService, 'fetchEksClusters').mockResolvedValue({
clusters: mockEKSClusters,
Expand All @@ -197,7 +198,9 @@ describe('test EnrollEksCluster.tsx', () => {

act(() => screen.getByRole('radio').click());

act(() => screen.getByText('Enroll EKS Cluster').click());
await waitFor(() => {
screen.getByText('Enroll EKS Cluster').click();
});

expect(discoveryService.createDiscoveryConfig).not.toHaveBeenCalled();
expect(KubeService.prototype.fetchKubernetes).toHaveBeenCalledTimes(1);
Expand Down
3 changes: 2 additions & 1 deletion web/packages/teleport/src/JoinTokens/JoinTokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,12 @@ function makeTokenResource(token: JoinToken): Resource<KindJoinToken> {

export const JoinTokens = () => {
const ctx = useTeleport();

const [creatingToken, setCreatingToken] = useState(false);
const [editingToken, setEditingToken] = useState<JoinToken | null>(null);
const [tokenToDelete, setTokenToDelete] = useState<JoinToken | null>(null);
const [joinTokensAttempt, runJoinTokensAttempt, setJoinTokensAttempt] =
useAsync(async () => await ctx.joinTokenService.fetchJoinTokens());
useAsync(() => ctx.joinTokenService.fetchJoinTokens());

const resources = useResources(
joinTokensAttempt.data?.items.map(makeTokenResource) || [],
Expand Down
54 changes: 54 additions & 0 deletions web/packages/teleport/src/MFAContext/MFAContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { createContext, PropsWithChildren, useCallback, useState } from 'react';

import AuthnDialog from 'teleport/components/AuthnDialog';
import { useMfa } from 'teleport/lib/useMfa';
import api from 'teleport/services/api/api';
import { CreateAuthenticateChallengeRequest } from 'teleport/services/auth';
import auth from 'teleport/services/auth/auth';
import { MfaChallengeResponse } from 'teleport/services/mfa';

export interface MfaContextValue {
getMfaChallengeResponse(
req: CreateAuthenticateChallengeRequest
): Promise<MfaChallengeResponse>;
}

export const MfaContext = createContext<MfaContextValue>(null);

/**
* Provides a global MFA context to handle MFA prompts for methods outside
* of the React scope, such as admin action API calls in auth.ts or api.ts.
* This is intended as a workaround for such cases, and should not be used
* for methods with access to the React scope. Use useMfa directly instead.
*/
export const MfaContextProvider = ({ children }: PropsWithChildren) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this unset the mfaContext in auth/api on unmount? (I'm not sure if that matters)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how unmounting in React works, how would I go about this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Joerger To execute code on unmount, you can add an useEffect that will only execute once and return a cleanup function. The callback will be executed when component is mounted, and then the returned cleanup function will be executed when it's being unmounted. Example:

useEffect(
  () => {
    return () => {
      console.log('unmounted!');
    }
  },
  []
);

The cleanup function is executed by React every time when the useEffect dependencies change, just before the next effect callback is called or when component is unmounted. This is typically used to cancel network requests, making sure that no excess bandwidth is consumed, but most of all to protect out-of-order responses to force the component into an unexpected state. But here, since there are no dependencies, the cleanup function will only be executed once.

(Also note that the cleanup function is the very reason why you can't pass an async function directly as an useEffect callback, and you'd have to wrap it into a synchronous one. React would treat the returned promise object as a function and try to call it, with rather predictable effect.)

const adminMfa = useMfa({});

const getMfaChallengeResponse = useCallback(
async (req: CreateAuthenticateChallengeRequest) => {
const chal = await auth.getMfaChallenge(req);

const res = await adminMfa.getChallengeResponse(chal);
if (!res) {
return {}; // return an empty challenge to prevent mfa retry.
}

return res;
},
[adminMfa]
);

const [mfaCtx] = useState<MfaContextValue>(() => {
const mfaCtx = { getMfaChallengeResponse };
auth.setMfaContext(mfaCtx);
api.setMfaContext(mfaCtx);
return mfaCtx;
});

return (
<MfaContext.Provider value={mfaCtx}>
<AuthnDialog mfaState={adminMfa}></AuthnDialog>
{children}
</MfaContext.Provider>
);
};
17 changes: 10 additions & 7 deletions web/packages/teleport/src/Teleport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { LoginFailedComponent as LoginFailed } from './Login/LoginFailed';
import { LoginSuccess } from './Login/LoginSuccess';
import { LoginTerminalRedirect } from './Login/LoginTerminalRedirect';
import { Main } from './Main';
import { MfaContextProvider } from './MFAContext/MFAContext';
import { Player } from './Player';
import { SingleLogoutFailed } from './SingleLogoutFailed';
import TeleportContext from './teleportContext';
Expand Down Expand Up @@ -86,13 +87,15 @@ const Teleport: React.FC<Props> = props => {
<Authenticated>
<UserContextProvider>
<TeleportContextProvider ctx={ctx}>
<Switch>
<Route
path={cfg.routes.appLauncher}
component={AppLauncher}
/>
<Route>{createPrivateRoutes()}</Route>
</Switch>
<MfaContextProvider>
<Switch>
<Route
path={cfg.routes.appLauncher}
component={AppLauncher}
/>
<Route>{createPrivateRoutes()}</Route>
</Switch>
</MfaContextProvider>
</TeleportContextProvider>
</UserContextProvider>
</Authenticated>
Expand Down
19 changes: 12 additions & 7 deletions web/packages/teleport/src/Users/useUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,18 @@ export default function useUsers({
}

async function onCreate(u: User) {
const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true);
return ctx.userService
.createUser(u, ExcludeUserField.Traits, mfaResponse)
.then(result => setUsers([result, ...users]))
.then(() =>
ctx.userService.createResetPasswordToken(u.name, 'invite', mfaResponse)
);
const mfaResponse = await auth.getAdminActionMfaResponse(true);
const result = await ctx.userService.createUser(
u,
ExcludeUserField.Traits,
mfaResponse
);
setUsers([result, ...users]);
return ctx.userService.createResetPasswordToken(
u.name,
'invite',
mfaResponse
);
}

function onInviteCollaboratorsClose() {
Expand Down
5 changes: 3 additions & 2 deletions web/packages/teleport/src/lib/useMfa.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,10 @@ describe('useMfa', () => {

mfa.current.cancelAttempt();

await expect(respPromise).rejects.toThrow(
new Error('User canceled MFA attempt')
const expectErr = new Error(
'User cancelled MFA attempt. This is an admin-level API request and requires MFA verification. Please try again with a registered MFA device. If you do not have an MFA device registered, you can add one in the account settings page.'
);
await expect(respPromise).rejects.toThrow(expectErr);

// If the user cancels the MFA attempt and closes the dialog, the mfa status
// should be 'success', or else the dialog would remain open to display the error.
Expand Down
6 changes: 5 additions & 1 deletion web/packages/teleport/src/lib/useMfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ export function useMfa({ req, isMfaRequired }: MfaProps): MfaState {

const cancelAttempt = () => {
if (mfaResponseRef.current) {
mfaResponseRef.current.resolve(new Error('User canceled MFA attempt'));
mfaResponseRef.current.resolve(
new Error(
'User cancelled MFA attempt. This is an admin-level API request and requires MFA verification. Please try again with a registered MFA device. If you do not have an MFA device registered, you can add one in the account settings page.'
)
);
}
};

Expand Down
58 changes: 45 additions & 13 deletions web/packages/teleport/src/services/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,33 @@

import 'whatwg-fetch';

import auth, { MfaChallengeScope } from 'teleport/services/auth/auth';
import { MfaContextValue } from 'teleport/MFAContext/MFAContext';
import websession from 'teleport/services/websession';

import { MfaChallengeScope } from '../auth/auth';
import { MfaChallengeResponse } from '../mfa';
import { storageService } from '../storageService';
import parseError, { ApiError } from './parseError';

export const MFA_HEADER = 'Teleport-Mfa-Response';

let mfaContext: MfaContextValue;

const api = {
get(url: string, abortSignal?: AbortSignal) {
return api.fetchJsonWithMfaAuthnRetry(url, { signal: abortSignal });
setMfaContext(mfa: MfaContextValue) {
mfaContext = mfa;
},

get(
url: string,
abortSignal?: AbortSignal,
mfaResponse?: MfaChallengeResponse
) {
return api.fetchJsonWithMfaAuthnRetry(
url,
{ signal: abortSignal },
mfaResponse
);
},

post(url, data?, abortSignal?, mfaResponse?: MfaChallengeResponse) {
Expand Down Expand Up @@ -179,23 +194,40 @@ const api = {
throw new ApiError(parseError(json), response, undefined, json.messages);
}

let mfaResponseForRetry;
const mfaResponseForRetry = await api.getAdminActionMfaResponse();

return api.fetchJsonWithMfaAuthnRetry(
url,
customOptions,
mfaResponseForRetry
);
},

getAdminActionMfaResponse(allowReuse?: boolean) {
// mfaContext is set once the react-scoped MFA Context Provider is initialized.
// Since this is a global object outside of the react scope, there is a marginal
// chance for a race condition here (the react scope should generally be initialized
// before this has a chance of being called). This conditional is not expected to
// be hit, but will catch any major issues that could arise from this solution.
if (!mfaContext) {
throw new Error(
'Failed to set up MFA prompt for admin action. Please try refreshing the page to try again. If the issue persists, contact support as this is likely a bug.'
);
}

try {
const challenge = await auth.getMfaChallenge({
return mfaContext.getMfaChallengeResponse({
scope: MfaChallengeScope.ADMIN_ACTION,
allowReuse,
isMfaRequiredRequest: {
admin_action: {},
},
});
mfaResponseForRetry = await auth.getMfaChallengeResponse(challenge);
} catch {
throw new Error(
'Failed to fetch MFA challenge. Please connect a registered hardware key and try again. If you do not have a hardware key registered, you can add one from your account settings page.'
'This is an admin-level API request and requires MFA verification. Please try again with a registered MFA device. If you do not have an MFA device registered, you can add one in the account settings page.'
);
}

return api.fetchJsonWithMfaAuthnRetry(
url,
customOptions,
mfaResponseForRetry
);
},

/**
Expand Down
59 changes: 30 additions & 29 deletions web/packages/teleport/src/services/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

import cfg from 'teleport/config';
import { MfaContextValue } from 'teleport/MFAContext/MFAContext';
import api from 'teleport/services/api';
import {
DeviceType,
Expand Down Expand Up @@ -44,7 +45,13 @@ import {
UserCredentials,
} from './types';

let mfaContext: MfaContextValue;

const auth = {
setMfaContext(mfa: MfaContextValue) {
mfaContext = mfa;
},

checkWebauthnSupport() {
if (window.PublicKeyCredential) {
return Promise.resolve();
Expand Down Expand Up @@ -277,24 +284,9 @@ const auth = {
// If is_mfa_required_req is provided and it is found that MFA is not required, returns null instead.
async getMfaChallengeResponse(
challenge: MfaAuthenticateChallenge,
mfaType?: DeviceType,
mfaType: DeviceType,
totpCode?: string
): Promise<MfaChallengeResponse | undefined> {
if (!challenge) return;

// TODO(Joerger): If mfaType is not provided by a parent component, use some global context
// to display a component, similar to the one used in useMfa. For now we just default to
// whichever method we can succeed with first.
if (!mfaType) {
if (totpCode) {
mfaType = 'totp';
} else if (challenge.webauthnPublicKey) {
mfaType = 'webauthn';
} else if (challenge.ssoChallenge) {
mfaType = 'sso';
}
}

if (mfaType === 'webauthn') {
return auth.getWebAuthnChallengeResponse(challenge.webauthnPublicKey);
}
Expand Down Expand Up @@ -389,7 +381,7 @@ const auth = {
createPrivilegeTokenWithWebauthn() {
return auth
.getMfaChallenge({ scope: MfaChallengeScope.MANAGE_DEVICES })
.then(auth.getMfaChallengeResponse)
.then(chal => auth.getMfaChallengeResponse(chal, 'webauthn'))
.then(mfaResp => auth.createPrivilegeToken(mfaResp));
},

Expand Down Expand Up @@ -442,27 +434,36 @@ const auth = {
.then(res => res?.webauthn_response);
},

getMfaChallengeResponseForAdminAction(allowReuse?: boolean) {
// If the client is checking if MFA is required for an admin action,
// but we know admin action MFA is not enforced, return early.
if (!cfg.isAdminActionMfaEnforced()) {
return;
getAdminActionMfaResponse(allowReuse?: boolean) {
// mfaContext is set once the react-scoped MFA Context Provider is initialized.
// Since this is a global object outside of the react scope, there is a marginal
// chance for a race condition here (the react scope should generally be initialized
// before this has a chance of being called). This conditional is not expected to
// be hit, but will catch any major issues that could arise from this solution.
Copy link
Contributor

@bl-nero bl-nero Jan 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. This is quite a hack. The fact that auth service started to depend on an MFA context that is injected on the top level by context provider makes me think that it should be turned into a class (just like the MFA service) that is instantiated by TeleportContext. This would allow establishing a link to the MFA service/context/whatever when constructing the auth service. Will it be a big refactoring?

Copy link
Contributor Author

@Joerger Joerger Jan 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does sound like a big refactor to me, but I'm not really in a position with my level of typescript/React experience to judge or carry out this type of refactor. I also have some other important work to move onto. Do you think it's something we can live with today and fix tomorrow, or do we need to scrap this approach for now?

Note that without this change, we still support SSO MFA for admin actions, we just don't provide the user with the choice between SSO MFA and Webauthn when applicable. Instead we automatically open the Webauthn or SSO MFA pop up without a dialog, which may be jarring, but not the end of the world. In the long term we should find a solution.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Joerger I think that given above, it would be best to continue with the PR as is, and treat this part as a technical debt that we take to improve UX. Can you please create a tracking issue for this refactoring?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!mfaContext) {
throw new Error(
'Failed to set up MFA prompt for admin action. Please try refreshing the page to try again. If the issue persists, contact support as this is likely a bug.'
);
}

return auth
.getMfaChallenge({
try {
return mfaContext.getMfaChallengeResponse({
scope: MfaChallengeScope.ADMIN_ACTION,
allowReuse: allowReuse,
allowReuse,
isMfaRequiredRequest: {
admin_action: {},
},
})
.then(auth.getMfaChallengeResponse);
});
} catch {
throw new Error(
'This is an admin-level API request and requires MFA verification. Please try again with a registered MFA device. If you do not have an MFA device registered, you can add one in the account settings page.'
);
}
},

// TODO(Joerger): Delete in favor of getMfaChallengeResponseForAdminAction once /e is updated.
// TODO(Joerger): Delete in favor of getAdminActionMfaResponse once /e is updated.
getWebauthnResponseForAdminAction(allowReuse?: boolean) {
return auth.getMfaChallengeResponseForAdminAction(allowReuse);
return auth.getAdminActionMfaResponse(allowReuse);
},
};

Expand Down
Loading
Loading