Skip to content

Commit

Permalink
Add SSO option to AuthDialog
Browse files Browse the repository at this point in the history
  • Loading branch information
avatus committed Oct 23, 2024
1 parent baa1545 commit 6187e65
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,38 @@ export default {
title: 'Teleport/AuthnDialog',
};

export const Loaded = () => <AuthnDialog {...props} />;
export const Loaded = () => {
const props: Props = {
...defaultProps,
mfa: {
...defaultProps.mfa,
ssoChallenge: {
redirectUrl: 'hi',
requestId: '123',
channelId: '123',
device: {
connectorId: '123',
connectorType: 'saml',
displayName: 'Okta',
},
},
},
};
return <AuthnDialog {...props} />;
};

export const Error = () => <AuthnDialog {...props} />;
export const Error = () => {
const props: Props = {
...defaultProps,
mfa: {
...defaultProps.mfa,
errorText: 'Something went wrong',
},
};
return <AuthnDialog {...props} />;
};

const props: Props = {
const defaultProps: Props = {
mfa: makeDefaultMfaState(),
onCancel: () => null,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React from 'react';
import { render, screen, fireEvent } from 'design/utils/testing';

import { makeDefaultMfaState, MfaState } from 'teleport/lib/useMfa';
import { SSOChallenge } from 'teleport/services/auth';

import AuthnDialog from './AuthnDialog';

const mockSsoChallenge: SSOChallenge = {
redirectUrl: 'url',
requestId: '123',
device: {
displayName: 'Okta',
connectorId: '123',
connectorType: 'saml',
},
channelId: '123',
};

function makeMockState(partial: Partial<MfaState>): MfaState {
const mfa = makeDefaultMfaState();
return {
...mfa,
...partial,
};
}

describe('AuthnDialog', () => {
const mockOnCancel = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
});

test('renders the dialog with basic content', () => {
const mfa = makeMockState({ ssoChallenge: mockSsoChallenge });
render(<AuthnDialog mfa={mfa} onCancel={mockOnCancel} />);

expect(screen.getByText('Multi-factor authentication')).toBeInTheDocument();
expect(
screen.getByText(
'Re-enter your multi-factor authentication in the browser to continue.'
)
).toBeInTheDocument();
expect(screen.getByText('Okta')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
});

test('displays error text when provided', () => {
const errorText = 'Authentication failed';
const mfa = makeMockState({ errorText });
render(<AuthnDialog mfa={mfa} onCancel={mockOnCancel} />);

expect(screen.getByTestId('danger-alert')).toBeInTheDocument();
expect(screen.getByText(errorText)).toBeInTheDocument();
});

test('sso button renders with callback', async () => {
const mfa = makeMockState({
ssoChallenge: mockSsoChallenge,
onSsoAuthenticate: jest.fn(),
});
render(<AuthnDialog mfa={mfa} onCancel={mockOnCancel} />);
const ssoButton = screen.getByText('Okta');
fireEvent.click(ssoButton);
expect(mfa.onSsoAuthenticate).toHaveBeenCalledTimes(1);
});

test('webauthn button renders with callback', async () => {
const mfa = makeMockState({
webauthnPublicKey: { challenge: new ArrayBuffer(0) },
onWebauthnAuthenticate: jest.fn(),
});
render(<AuthnDialog mfa={mfa} onCancel={mockOnCancel} />);
const webauthn = screen.getByText('Passkey/Hardware Key');
fireEvent.click(webauthn);
expect(mfa.onWebauthnAuthenticate).toHaveBeenCalledTimes(1);
});
});
46 changes: 31 additions & 15 deletions web/packages/teleport/src/components/AuthnDialog/AuthnDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,40 +23,56 @@ import Dialog, {
DialogContent,
} from 'design/Dialog';
import { Danger } from 'design/Alert';
import { Text, ButtonPrimary, ButtonSecondary, Flex } from 'design';
import { Text, ButtonSecondary, Flex, ButtonLink } from 'design';

import { guessProviderType } from 'shared/components/ButtonSso';
import { SSOIcon } from 'shared/components/ButtonSso/ButtonSso';

import { MfaState } from 'teleport/lib/useMfa';

export default function AuthnDialog({ mfa, onCancel }: Props) {
return (
<Dialog dialogCss={() => ({ width: '500px' })} open={true}>
<Dialog dialogCss={() => ({ width: '400px' })} open={true}>
<DialogHeader style={{ flexDirection: 'column' }}>
<DialogTitle textAlign="center">
Multi-factor authentication
</DialogTitle>
</DialogHeader>
<DialogContent mb={6}>
{mfa.errorText && (
<Danger mt={2} width="100%">
<Danger data-testid="danger-alert" mt={2} width="100%">
{mfa.errorText}
</Danger>
)}
<Text textAlign="center">
Re-enter your multi-factor authentication in the browser to continue.
</Text>
</DialogContent>
<Flex textAlign="center" justifyContent="center">
{/* TODO (avatus) this will eventually be conditionally rendered based on what
type of challenges exist. For now, its only webauthn. */}
<ButtonPrimary
onClick={mfa.onWebauthnAuthenticate}
autoFocus
mr={3}
width="130px"
>
{mfa.errorText ? 'Retry' : 'OK'}
</ButtonPrimary>
<ButtonSecondary onClick={onCancel}>Cancel</ButtonSecondary>
<Flex textAlign="center" width="100%" flexDirection="column" gap={3}>
{mfa.ssoChallenge && (
<ButtonSecondary
onClick={mfa.onSsoAuthenticate}
autoFocus
gap={2}
block
>
<SSOIcon
type={guessProviderType(
mfa.ssoChallenge.device.displayName,
mfa.ssoChallenge.device.connectorType
)}
/>
{mfa.ssoChallenge.device.displayName}
</ButtonSecondary>
)}
{mfa.webauthnPublicKey && (
<ButtonSecondary onClick={mfa.onWebauthnAuthenticate} autoFocus block>
Passkey/Hardware Key
</ButtonSecondary>
)}
<ButtonLink block onClick={onCancel}>
Cancel
</ButtonLink>
</Flex>
</Dialog>
);
Expand Down
14 changes: 13 additions & 1 deletion web/packages/teleport/src/lib/EventEmitterMfaSender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,25 @@

import { EventEmitter } from 'events';

import { WebauthnAssertionResponse } from 'teleport/services/auth';
import {
MfaChallengeResponse,
WebauthnAssertionResponse,
} from 'teleport/services/auth';

class EventEmitterMfaSender extends EventEmitter {
constructor() {
super();
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
sendChallengeResponse(data: MfaChallengeResponse) {
throw new Error('Not implemented');
}

// TODO (avatus) DELETE IN 19
/**
* @deprecated Use sendChallengeResponse instead.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
sendWebAuthn(data: WebauthnAssertionResponse) {
throw new Error('Not implemented');
Expand Down
28 changes: 27 additions & 1 deletion web/packages/teleport/src/lib/term/tty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
import Logger from 'shared/libs/logger';

import { EventEmitterMfaSender } from 'teleport/lib/EventEmitterMfaSender';
import { WebauthnAssertionResponse } from 'teleport/services/auth';
import {
MfaChallengeResponse,
WebauthnAssertionResponse,
} from 'teleport/services/auth';
import { AuthenticatedWebSocket } from 'teleport/lib/AuthenticatedWebSocket';

import { EventType, TermEvent, WebsocketCloseCode } from './enums';
Expand Down Expand Up @@ -80,6 +83,29 @@ class Tty extends EventEmitterMfaSender {
this.socket.send(bytearray.buffer);
}

sendChallengeResponse(data: MfaChallengeResponse) {
// we want to have the backend listen on a single message type
// for any responses. so our data will look like data.webauthn, data.sso, etc
// but to be backward compatible, we need to still spread the existing webauthn only fields
// as "top level" fields so old proxies can still respond to webauthn challenges.
// in 19, we can just pass "data" without this extra step
// TODO (avatus): DELETE IN 19
const backwardCompatibleData = {
...data.webauthn_response,
...data,
};
console.log({ data });
const encoded = this._proto.encodeChallengeResponse(
JSON.stringify(backwardCompatibleData)
);
const bytearray = new Uint8Array(encoded);
this.socket.send(bytearray);
}

// TODO (avatus) DELETE IN 19
/**
* @deprecated Use sendChallengeResponse instead.
*/
sendWebAuthn(data: WebauthnAssertionResponse) {
const encoded = this._proto.encodeChallengeResponse(JSON.stringify(data));
const bytearray = new Uint8Array(encoded);
Expand Down
62 changes: 58 additions & 4 deletions web/packages/teleport/src/lib/useMfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,64 @@ export function useMfa(emitterSender: EventEmitterMfaSender): MfaState {
totpChallenge: false,
});

// TODO (avatus), this is stubbed for types but will not be called
// until SSO as MFA backend is in.
function clearChallenges() {
setState(prevState => ({
...prevState,
totpChallenge: false,
webauthnPublicKey: null,
ssoChallenge: null,
}));
}

// open a broadcast channel if sso challenge exists so it can listen
// for a confirmation response token
useEffect(() => {
if (!state.ssoChallenge) {
return;
}

const channel = new BroadcastChannel(state.ssoChallenge.channelId);

function handleMessage(e: MessageEvent<{ mfaToken: string }>) {
if (!state.ssoChallenge) {
return;
}

emitterSender.sendChallengeResponse({
sso_response: {
requestId: state.ssoChallenge.requestId,
token: e.data.mfaToken,
},
});
clearChallenges();
}

channel.addEventListener('message', handleMessage);

return () => {
channel.removeEventListener('message', handleMessage);
channel.close();
};
}, [state, emitterSender, state.ssoChallenge]);

function onSsoAuthenticate() {
// eslint-disable-next-line no-console
console.error('not yet implemented');
if (!state.ssoChallenge) {
setState(prevState => ({
...prevState,
errorText: 'Invalid or missing SSO challenge',
}));
return;
}

// try to center the screen
const width = 1045;
const height = 550;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;

// these params will open a tiny window.
const params = `width=${width},height=${height},left=${left},top=${top}`;
window.open(state.ssoChallenge.redirectUrl, '_blank', params);
}

function onWebauthnAuthenticate() {
Expand Down Expand Up @@ -83,6 +136,7 @@ export function useMfa(emitterSender: EventEmitterMfaSender): MfaState {

setState(prevState => ({
...prevState,
addMfaToScpUrls: true,
ssoChallenge,
webauthnPublicKey,
totpChallenge,
Expand Down
Loading

0 comments on commit 6187e65

Please sign in to comment.