diff --git a/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx
index d5b334860f31c..cefe2963c787a 100644
--- a/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx
+++ b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx
@@ -23,20 +23,29 @@ import { render, screen } from 'design/utils/testing';
import cfg from 'teleport/config';
import { HeadlessRequest } from 'teleport/HeadlessRequest/HeadlessRequest';
+import { shouldShowMfaPrompt } from 'teleport/lib/useMfa';
import auth from 'teleport/services/auth';
-test('ip address should be visible', async () => {
- jest.spyOn(auth, 'headlessSsoGet').mockImplementation(
- () =>
- new Promise(resolve => {
- resolve({ clientIpAddress: '1.2.3.4' });
- })
- );
+const mockGetChallengeResponse = jest.fn();
- const headlessSSOPath = '/web/headless/2a8dcaae-1fa5-533b-aad8-f97420df44de';
- const mockHistory = createMemoryHistory({
- initialEntries: [headlessSSOPath],
- });
+jest.mock('teleport/lib/useMfa', () => ({
+ useMfa: () => ({
+ getChallengeResponse: mockGetChallengeResponse,
+ attempt: { status: '' },
+ }),
+ shouldShowMfaPrompt: jest.fn(),
+}));
+
+function setup({ mfaPrompt = false, path = '/web/headless/123' } = {}) {
+ (shouldShowMfaPrompt as jest.Mock).mockReturnValue(mfaPrompt);
+
+ mockGetChallengeResponse.mockResolvedValue({ webauthn_response: {} });
+
+ jest
+ .spyOn(auth, 'headlessSsoGet')
+ .mockResolvedValue({ clientIpAddress: '1.2.3.4' });
+
+ const mockHistory = createMemoryHistory({ initialEntries: [path] });
render(
@@ -45,8 +54,31 @@ test('ip address should be visible', async () => {
);
+}
+
+describe('HeadlessRequest', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('shows the headless request approve/reject dialog', async () => {
+ setup({ mfaPrompt: false, path: '/web/headless/abc' });
- await expect(
- screen.findByText(/Someone has initiated a command from 1.2.3.4/i)
- ).resolves.toBeInTheDocument();
+ await expect(
+ screen.findByText(/Someone has initiated a command from 1.2.3.4/i)
+ ).resolves.toBeInTheDocument();
+
+ expect(
+ await screen.findByRole('button', { name: /Approve/i })
+ ).toBeInTheDocument();
+ expect(
+ await screen.findByRole('button', { name: /Reject/i })
+ ).toBeInTheDocument();
+ });
+
+ test('shows MFA prompt after user approves the request', async () => {
+ setup({ mfaPrompt: true, path: '/web/headless/abc' });
+
+ expect(await screen.findAllByText(/Verify Your Identity/i)).toHaveLength(2);
+ });
});
diff --git a/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx
index 9950cbac4c2ac..c732e6143819c 100644
--- a/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx
+++ b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx
@@ -22,10 +22,13 @@ import styled from 'styled-components';
import { Box, Flex, rotate360 } from 'design';
import { Spinner } from 'design/Icon';
+import AuthnDialog from 'teleport/components/AuthnDialog';
import HeadlessRequestDialog from 'teleport/components/HeadlessRequestDialog/HeadlessRequestDialog';
import { useParams } from 'teleport/components/Router';
import { CardAccept, CardDenied } from 'teleport/HeadlessRequest/Cards';
+import { shouldShowMfaPrompt, useMfa } from 'teleport/lib/useMfa';
import auth from 'teleport/services/auth';
+import { MfaChallengeScope } from 'teleport/services/auth/auth';
export function HeadlessRequest() {
const { requestId } = useParams<{ requestId: string }>();
@@ -37,6 +40,13 @@ export function HeadlessRequest() {
publicKey: null as PublicKeyCredentialRequestOptions,
});
+ const mfa = useMfa({
+ req: {
+ scope: MfaChallengeScope.HEADLESS_LOGIN,
+ },
+ isMfaRequired: true,
+ });
+
useEffect(() => {
const setIpAddress = (response: { clientIpAddress: string }) => {
setState({
@@ -100,38 +110,47 @@ export function HeadlessRequest() {
}
return (
- {
- setState({ ...state, status: 'in-progress' });
-
- auth
- .headlessSsoAccept(requestId)
- .then(setSuccess)
- .catch(e => {
- setState({
- ...state,
- status: 'error',
- errorText: e.toString(),
- });
- });
- }}
- onReject={() => {
- setState({ ...state, status: 'in-progress' });
-
- auth
- .headlessSSOReject(requestId)
- .then(setRejected)
- .catch(e => {
- setState({
- ...state,
- status: 'error',
- errorText: e.toString(),
- });
- });
- }}
- errorText={state.errorText}
- />
+ <>
+ {
+ /* Show only one dialog at a time because too many dialogs can be confusing */
+ shouldShowMfaPrompt(mfa) ? (
+
+ ) : (
+ {
+ setState({ ...state, status: 'in-progress' });
+
+ auth
+ .headlessSsoAccept(mfa, requestId)
+ .then(setSuccess)
+ .catch(e => {
+ setState({
+ ...state,
+ status: 'error',
+ errorText: e.toString(),
+ });
+ });
+ }}
+ onReject={() => {
+ setState({ ...state, status: 'in-progress' });
+
+ auth
+ .headlessSSOReject(requestId)
+ .then(setRejected)
+ .catch(e => {
+ setState({
+ ...state,
+ status: 'error',
+ errorText: e.toString(),
+ });
+ });
+ }}
+ errorText={state.errorText}
+ />
+ )
+ }
+ >
);
}
diff --git a/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx b/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx
index f13dfbbd505e8..0bbdf6fea879c 100644
--- a/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx
+++ b/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx
@@ -59,7 +59,9 @@ export default function HeadlessRequestDialog({
you, click Reject and contact your administrator.
- If it was you, please use your hardware key to approve.
+ If it was you, approve the request to continue. You will be
+ prompted to verify your identity in order to complete the
+ approval.
>
)}
diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts
index eb0e9d864a797..685edad790aa6 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 { MfaState } from 'teleport/lib/useMfa';
import api from 'teleport/services/api';
import {
DeviceType,
@@ -228,20 +229,17 @@ const auth = {
});
},
- headlessSsoAccept(transactionId: string) {
- return auth
- .getMfaChallenge({ scope: MfaChallengeScope.HEADLESS_LOGIN })
- .then(challenge => auth.getMfaChallengeResponse(challenge))
- .then(res => {
- const request = {
- action: 'accept',
- mfaResponse: res,
- // TODO(Joerger): DELETE IN v19.0.0, new clients send mfaResponse.
- webauthnAssertionResponse: res.webauthn_response,
- };
+ headlessSsoAccept(mfa: MfaState, transactionId: string) {
+ return mfa.getChallengeResponse().then((res: MfaChallengeResponse) => {
+ const request = {
+ action: 'accept',
+ mfaResponse: res,
+ // TODO(Joerger): DELETE IN v19.0.0, new clients send mfaResponse.
+ webauthnAssertionResponse: res.webauthn_response,
+ };
- return api.put(cfg.getHeadlessSsoPath(transactionId), request);
- });
+ return api.put(cfg.getHeadlessSsoPath(transactionId), request);
+ });
},
headlessSSOReject(transactionId: string) {