From f7499429aa0163676ba5086c635c835390623485 Mon Sep 17 00:00:00 2001 From: Chris Thach Date: Thu, 11 Sep 2025 15:51:45 -0400 Subject: [PATCH 1/2] fix: Add SSO MFA support to headless login Signed-off-by: Chris Thach --- .../HeadlessRequest/HeadlessRequest.test.tsx | 60 ++++++++++---- .../src/HeadlessRequest/HeadlessRequest.tsx | 83 ++++++++++++------- .../HeadlessRequestDialog.tsx | 4 +- .../teleport/src/services/auth/auth.ts | 24 +++--- 4 files changed, 111 insertions(+), 60 deletions(-) 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..6858fcc79c7cc 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) { From d94314e7368ed20cb157aea29593b48cc587acf2 Mon Sep 17 00:00:00 2001 From: Chris Thach Date: Fri, 12 Sep 2025 09:07:01 -0400 Subject: [PATCH 2/2] refactor: Lowercased approve --- .../components/HeadlessRequestDialog/HeadlessRequestDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx b/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx index 6858fcc79c7cc..0bbdf6fea879c 100644 --- a/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx +++ b/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx @@ -59,7 +59,7 @@ export default function HeadlessRequestDialog({ you, click Reject and contact your administrator.

- If it was you, Approve the request to continue. You will be + If it was you, approve the request to continue. You will be prompted to verify your identity in order to complete the approval.