Skip to content
Merged
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
60 changes: 46 additions & 14 deletions web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Router history={mockHistory}>
Expand All @@ -45,8 +54,31 @@ test('ip address should be visible', async () => {
</Route>
</Router>
);
}

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);
});
});
83 changes: 51 additions & 32 deletions web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Expand All @@ -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({
Expand Down Expand Up @@ -100,38 +110,47 @@ export function HeadlessRequest() {
}

return (
<HeadlessRequestDialog
ipAddress={state.ipAddress}
onAccept={() => {
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) ? (
<AuthnDialog mfaState={mfa} />
) : (
<HeadlessRequestDialog
ipAddress={state.ipAddress}
onAccept={() => {
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}
/>
)
}
</>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export default function HeadlessRequestDialog({
you, click Reject and contact your administrator.
<br />
<br />
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.
</>
)}
</Text>
Expand Down
24 changes: 11 additions & 13 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 { MfaState } from 'teleport/lib/useMfa';
import api from 'teleport/services/api';
import {
DeviceType,
Expand Down Expand Up @@ -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) {
Expand Down
Loading