diff --git a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEKSCluster.test.tsx b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEKSCluster.test.tsx index 7cfb7dfd93b7d..75bbc151d6181 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEKSCluster.test.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEKSCluster.test.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -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'; @@ -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, @@ -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); diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx index 909f2c885605d..4f53a90e25a9d 100644 --- a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx @@ -70,11 +70,12 @@ function makeTokenResource(token: JoinToken): Resource { export const JoinTokens = () => { const ctx = useTeleport(); + const [creatingToken, setCreatingToken] = useState(false); const [editingToken, setEditingToken] = useState(null); const [tokenToDelete, setTokenToDelete] = useState(null); const [joinTokensAttempt, runJoinTokensAttempt, setJoinTokensAttempt] = - useAsync(async () => await ctx.joinTokenService.fetchJoinTokens()); + useAsync(() => ctx.joinTokenService.fetchJoinTokens()); const resources = useResources( joinTokensAttempt.data?.items.map(makeTokenResource) || [], diff --git a/web/packages/teleport/src/MFAContext/MFAContext.tsx b/web/packages/teleport/src/MFAContext/MFAContext.tsx new file mode 100644 index 0000000000000..fcb83604bb900 --- /dev/null +++ b/web/packages/teleport/src/MFAContext/MFAContext.tsx @@ -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; +} + +export const MfaContext = createContext(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) => { + 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(() => { + const mfaCtx = { getMfaChallengeResponse }; + auth.setMfaContext(mfaCtx); + api.setMfaContext(mfaCtx); + return mfaCtx; + }); + + return ( + + + {children} + + ); +}; diff --git a/web/packages/teleport/src/Teleport.tsx b/web/packages/teleport/src/Teleport.tsx index 0c1e35918c24e..8dce3f6460c0d 100644 --- a/web/packages/teleport/src/Teleport.tsx +++ b/web/packages/teleport/src/Teleport.tsx @@ -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'; @@ -86,13 +87,15 @@ const Teleport: React.FC = props => { - - - {createPrivateRoutes()} - + + + + {createPrivateRoutes()} + + diff --git a/web/packages/teleport/src/Users/useUsers.ts b/web/packages/teleport/src/Users/useUsers.ts index 48dc99dd11637..3716e859b9ca3 100644 --- a/web/packages/teleport/src/Users/useUsers.ts +++ b/web/packages/teleport/src/Users/useUsers.ts @@ -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() { diff --git a/web/packages/teleport/src/lib/useMfa.test.tsx b/web/packages/teleport/src/lib/useMfa.test.tsx index dad6b2645b70b..cfcb3d1baf555 100644 --- a/web/packages/teleport/src/lib/useMfa.test.tsx +++ b/web/packages/teleport/src/lib/useMfa.test.tsx @@ -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. diff --git a/web/packages/teleport/src/lib/useMfa.ts b/web/packages/teleport/src/lib/useMfa.ts index af1e3d531330e..ca5e97b020b16 100644 --- a/web/packages/teleport/src/lib/useMfa.ts +++ b/web/packages/teleport/src/lib/useMfa.ts @@ -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.' + ) + ); } }; diff --git a/web/packages/teleport/src/services/api/api.ts b/web/packages/teleport/src/services/api/api.ts index 5b19aef0bf580..73e641accfb65 100644 --- a/web/packages/teleport/src/services/api/api.ts +++ b/web/packages/teleport/src/services/api/api.ts @@ -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) { @@ -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 - ); }, /** diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts index 100259d6dfc20..3f2af5aecf88e 100644 --- a/web/packages/teleport/src/services/auth/auth.ts +++ b/web/packages/teleport/src/services/auth/auth.ts @@ -17,6 +17,7 @@ */ import cfg from 'teleport/config'; +import { MfaContextValue } from 'teleport/MFAContext/MFAContext'; import api from 'teleport/services/api'; import { DeviceType, @@ -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(); @@ -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 { - 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); } @@ -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)); }, @@ -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. + 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); }, }; diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 7b1ffa0b1724d..8ae24bfb44192 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -21,7 +21,7 @@ import api from 'teleport/services/api'; import { App } from '../apps'; import makeApp from '../apps/makeApps'; -import auth, { MfaChallengeScope } from '../auth/auth'; +import auth from '../auth/auth'; import makeNode from '../nodes/makeNode'; import { AwsDatabaseVpcsResponse, @@ -271,24 +271,14 @@ export const integrationService = { integrationName, req: AwsOidcDeployServiceRequest ): Promise { - const challenge = await auth.getMfaChallenge({ - scope: MfaChallengeScope.ADMIN_ACTION, - allowReuse: true, - isMfaRequiredRequest: { - admin_action: {}, - }, - }); - - const response = await auth.getMfaChallengeResponse(challenge); - - return api - .post( - cfg.getAwsDeployTeleportServiceUrl(integrationName), - req, - null, - response - ) - .then(resp => resp.serviceDashboardUrl); + const mfaResp = await auth.getAdminActionMfaResponse(true); + const resp = await api.post( + cfg.getAwsDeployTeleportServiceUrl(integrationName), + req, + null, + mfaResp + ); + return resp.serviceDashboardUrl; }, async createAwsAppAccess(integrationName): Promise { @@ -301,23 +291,21 @@ export const integrationService = { integrationName, req: AwsOidcDeployDatabaseServicesRequest ): Promise { - const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true); - - return api - .post( - cfg.getAwsRdsDbsDeployServicesUrl(integrationName), - req, - null, - mfaResponse - ) - .then(resp => resp.clusterDashboardUrl); + const mfaResponse = await auth.getAdminActionMfaResponse(true); + const resp = await api.post( + cfg.getAwsRdsDbsDeployServicesUrl(integrationName), + req, + null, + mfaResponse + ); + return resp.clusterDashboardUrl; }, async enrollEksClusters( integrationName: string, req: EnrollEksClustersRequest ): Promise { - const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true); + const mfaResponse = await auth.getAdminActionMfaResponse(true); return api.post( cfg.getEnrollEksClusterUrl(integrationName), diff --git a/web/packages/teleport/src/services/joinToken/joinToken.test.ts b/web/packages/teleport/src/services/joinToken/joinToken.test.ts index 1f941345c1006..a748a55cbbcdd 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.test.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.test.ts @@ -19,15 +19,17 @@ import cfg from 'teleport/config'; import api from 'teleport/services/api'; +import auth from '../auth/auth'; import JoinTokenService from './joinToken'; import type { JoinTokenRequest } from './types'; -test('fetchJoinToken with an empty request properly sets defaults', () => { +test('fetchJoinToken with an empty request properly sets defaults', async () => { const svc = new JoinTokenService(); jest.spyOn(api, 'post').mockResolvedValue(null); + jest.spyOn(auth, 'getAdminActionMfaResponse').mockResolvedValue(null); // Test with all empty fields. - svc.fetchJoinToken({} as any); + await svc.fetchJoinToken({} as any); expect(api.post).toHaveBeenCalledWith( cfg.getJoinTokenUrl(), { @@ -36,13 +38,15 @@ test('fetchJoinToken with an empty request properly sets defaults', () => { allow: [], suggested_agent_matcher_labels: {}, }, + null, null ); }); -test('fetchJoinToken request fields are set as requested', () => { +test('fetchJoinToken request fields are set as requested', async () => { const svc = new JoinTokenService(); jest.spyOn(api, 'post').mockResolvedValue(null); + jest.spyOn(auth, 'getAdminActionMfaResponse').mockResolvedValue(null); const mock: JoinTokenRequest = { roles: ['Node'], @@ -50,7 +54,8 @@ test('fetchJoinToken request fields are set as requested', () => { method: 'iam', suggestedAgentMatcherLabels: [{ name: 'env', value: 'dev' }], }; - svc.fetchJoinToken(mock); + await svc.fetchJoinToken(mock); + expect(api.post).toHaveBeenCalledWith( cfg.getJoinTokenUrl(), { @@ -59,6 +64,7 @@ test('fetchJoinToken request fields are set as requested', () => { allow: [{ aws_account: '1234', aws_arn: 'xxxx' }], suggested_agent_matcher_labels: { env: ['dev'] }, }, + null, null ); }); diff --git a/web/packages/teleport/src/services/joinToken/joinToken.ts b/web/packages/teleport/src/services/joinToken/joinToken.ts index fe564b4440dae..298742acec108 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.ts @@ -20,6 +20,7 @@ import cfg from 'teleport/config'; import api from 'teleport/services/api'; import { makeLabelMapOfStrArrs } from '../agents/make'; +import auth from '../auth/auth'; import makeJoinToken from './makeJoinToken'; import { JoinRule, JoinToken, JoinTokenRequest } from './types'; @@ -27,61 +28,70 @@ const TeleportTokenNameHeader = 'X-Teleport-TokenName'; class JoinTokenService { // TODO (avatus) refactor this code to eventually use `createJoinToken` - fetchJoinToken( + async fetchJoinToken( req: JoinTokenRequest, signal: AbortSignal = null ): Promise { - return api - .post( - cfg.getJoinTokenUrl(), - { - roles: req.roles, - join_method: req.method || 'token', - allow: makeAllowField(req.rules || []), - suggested_agent_matcher_labels: makeLabelMapOfStrArrs( - req.suggestedAgentMatcherLabels - ), - }, - signal - ) - .then(makeJoinToken); + const mfaResponse = await auth.getAdminActionMfaResponse(); + const resp = await api.post( + cfg.getJoinTokenUrl(), + { + roles: req.roles, + join_method: req.method || 'token', + allow: makeAllowField(req.rules || []), + suggested_agent_matcher_labels: makeLabelMapOfStrArrs( + req.suggestedAgentMatcherLabels + ), + }, + signal, + mfaResponse + ); + return makeJoinToken(resp); } - upsertJoinTokenYAML( + async upsertJoinTokenYAML( req: JoinTokenRequest, tokenName: string ): Promise { - return api - .putWithHeaders( - cfg.getJoinTokenYamlUrl(), - { - content: req.content, - }, - { - [TeleportTokenNameHeader]: tokenName, - 'Content-Type': 'application/json', - } - ) - .then(makeJoinToken); + const mfaResponse = await auth.getAdminActionMfaResponse(); + const resp = await api.putWithHeaders( + cfg.getJoinTokenYamlUrl(), + { + content: req.content, + }, + { + [TeleportTokenNameHeader]: tokenName, + 'Content-Type': 'application/json', + }, + mfaResponse + ); + return makeJoinToken(resp); } - createJoinToken(req: JoinTokenRequest): Promise { - return api.post(cfg.getJoinTokensUrl(), req).then(makeJoinToken); + async createJoinToken(req: JoinTokenRequest): Promise { + const mfaResponse = await auth.getAdminActionMfaResponse(); + const resp = api.post(cfg.getJoinTokensUrl(), req, mfaResponse); + return makeJoinToken(resp); } - fetchJoinTokens(signal: AbortSignal = null): Promise<{ items: JoinToken[] }> { - return api.get(cfg.getJoinTokensUrl(), signal).then(resp => { + async fetchJoinTokens( + signal: AbortSignal = null + ): Promise<{ items: JoinToken[] }> { + const mfaResponse = await auth.getAdminActionMfaResponse(); + return api.get(cfg.getJoinTokensUrl(), signal, mfaResponse).then(resp => { return { items: resp.items?.map(makeJoinToken) || [], }; }); } - deleteJoinToken(id: string, signal: AbortSignal = null) { + async deleteJoinToken(id: string, signal: AbortSignal = null) { + const mfaResponse = await auth.getAdminActionMfaResponse(); return api.deleteWithHeaders( cfg.getJoinTokensUrl(), { [TeleportTokenNameHeader]: id }, - signal + signal, + mfaResponse ); } }