import {Component} from 'react'; import * as Sentry from '@sentry/react'; import * as cbor from 'cbor-web'; import {base64urlToBuffer, bufferToBase64url} from 'sentry/components/u2f/webAuthnHelper'; import {t, tct} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import {ChallengeData, Organization} from 'sentry/types'; import withOrganization from 'sentry/utils/withOrganization'; type TapParams = { challenge: string; response: string; isSuperuserModal?: boolean; superuserAccessCategory?: string; superuserReason?: string; }; type Props = { challengeData: ChallengeData; flowMode: string; onTap: ({ response, challenge, isSuperuserModal, superuserAccessCategory, superuserReason, }: TapParams) => Promise; organization: Organization; silentIfUnsupported: boolean; style?: React.CSSProperties; }; type State = { challengeElement: HTMLInputElement | null; deviceFailure: string | null; failCount: number; formElement: HTMLFormElement | null; hasBeenTapped: boolean; isSafari: boolean; isSupported: boolean | null; responseElement: HTMLInputElement | null; }; class U2fInterface extends Component { state: State = { isSupported: null, formElement: null, challengeElement: null, hasBeenTapped: false, deviceFailure: null, responseElement: null, isSafari: false, failCount: 0, }; componentDidMount() { const supported = !!window.PublicKeyCredential; // eslint-disable-next-line react/no-did-mount-set-state this.setState({isSupported: supported}); const isSafari = navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome'); if (isSafari) { // eslint-disable-next-line react/no-did-mount-set-state this.setState({ deviceFailure: 'safari: requires interaction', isSafari, hasBeenTapped: false, }); } if (supported && !isSafari) { this.invokeU2fFlow(); } } getU2FResponse(data) { if (!data.response) { return JSON.stringify(data); } if (this.props.flowMode === 'sign') { const authenticatorData = { keyHandle: data.id, clientData: bufferToBase64url(data.response.clientDataJSON), signatureData: bufferToBase64url(data.response.signature), authenticatorData: bufferToBase64url(data.response.authenticatorData), }; return JSON.stringify(authenticatorData); } if (this.props.flowMode === 'enroll') { const authenticatorData = { id: data.id, rawId: bufferToBase64url(data.rawId), response: { attestationObject: bufferToBase64url(data.response.attestationObject), clientDataJSON: bufferToBase64url(data.response.clientDataJSON), }, type: bufferToBase64url(data.type), }; return JSON.stringify(authenticatorData); } throw new Error(`Unsupported flow mode '${this.props.flowMode}'`); } submitU2fResponse(promise) { promise .then(data => { this.setState( { hasBeenTapped: true, }, () => { const u2fResponse = this.getU2FResponse(data); const challenge = JSON.stringify(this.props.challengeData); if (this.state.responseElement) { // eslint-disable-next-line react/no-direct-mutation-state this.state.responseElement.value = u2fResponse; } if (!this.props.onTap) { this.state.formElement?.submit(); return; } this.props .onTap({ response: u2fResponse, challenge, }) .catch(() => { // This is kind of gross but I want to limit the amount of changes to this component this.setState({ deviceFailure: 'UNKNOWN_ERROR', hasBeenTapped: false, }); }); } ); }) .catch(err => { let failure = 'DEVICE_ERROR'; // in some rare cases there is no metadata on the error which // causes this to blow up badly. if (err.metaData) { if (err.metaData.type === 'DEVICE_INELIGIBLE') { if (this.props.flowMode === 'enroll') { failure = 'DUPLICATE_DEVICE'; } else { failure = 'UNKNOWN_DEVICE'; } } else if (err.metaData.type === 'BAD_REQUEST') { failure = 'BAD_APPID'; } } // we want to know what is happening here. There are some indicators // that users are getting errors that should not happen through the // regular u2f flow. Sentry.captureException(err); this.setState({ deviceFailure: failure, hasBeenTapped: false, failCount: this.state.failCount + 1, }); }); } webAuthnSignIn(publicKeyCredentialRequestOptions) { const promise = navigator.credentials.get({ publicKey: publicKeyCredentialRequestOptions, }); this.submitU2fResponse(promise); } webAuthnRegister(publicKey) { const promise = navigator.credentials.create({ publicKey, }); this.submitU2fResponse(promise); } invokeU2fFlow() { if (this.props.flowMode === 'sign') { const challengeArray = base64urlToBuffer( this.props.challengeData.webAuthnAuthenticationData ); const challenge = cbor.decodeFirst(challengeArray); challenge .then(data => { this.webAuthnSignIn(data); }) .catch(err => { const failure = 'DEVICE_ERROR'; Sentry.captureException(err); this.setState({ deviceFailure: failure, hasBeenTapped: false, }); }); } else if (this.props.flowMode === 'enroll') { const challengeArray = base64urlToBuffer( this.props.challengeData.webAuthnRegisterData ); const challenge = cbor.decodeFirst(challengeArray); // challenge contains a PublicKeyCredentialRequestOptions object for webauthn registration challenge .then(data => { this.webAuthnRegister(data.publicKey); }) .catch(err => { const failure = 'DEVICE_ERROR'; Sentry.captureException(err); this.setState({ deviceFailure: failure, hasBeenTapped: false, }); }); } else { throw new Error(`Unsupported flow mode '${this.props.flowMode}'`); } } onTryAgain = () => { this.setState( {hasBeenTapped: false, deviceFailure: null}, () => void this.invokeU2fFlow() ); }; bindChallengeElement: React.RefCallback = ref => { this.setState({ challengeElement: ref, formElement: ref && ref.form, }); if (ref) { ref.value = JSON.stringify(this.props.challengeData); } }; bindResponseElement: React.RefCallback = ref => this.setState({responseElement: ref}); renderUnsupported() { return this.props.silentIfUnsupported ? null : (

{t( ` Unfortunately your browser does not support U2F. You need to use a different two-factor method or switch to a browser that supports it (Google Chrome or Microsoft Edge).` )}

); } get canTryAgain() { return this.state.deviceFailure !== 'BAD_APPID'; } renderSafariWebAuthn = () => { return ( {this.props.flowMode === 'enroll' ? t('Enroll with WebAuthn') : t('Sign in with WebAuthn')} ); }; renderFailure = () => { const {deviceFailure} = this.state; const supportMail = ConfigStore.get('supportEmail'); const support = supportMail ? ( {supportMail} ) : ( {t('Support')} ); if (this.state.isSafari && this.state.failCount === 0) { return this.renderSafariWebAuthn(); } return (
{t('Error: ')}{' '} { { UNKNOWN_ERROR: t('There was an unknown problem, please try again'), DEVICE_ERROR: t('Your U2F device reported an error.'), DUPLICATE_DEVICE: t('This device is already registered with Sentry.'), UNKNOWN_DEVICE: t('The device you used for sign-in is unknown.'), BAD_APPID: tct( `[p1:The Sentry server administrator modified the device registrations.] [p2:You need to remove and re-add the device to continue using your U2F device. Use a different sign-in method or contact [support] for assistance.]`, { p1:

, p2:

, support, } ), }[deviceFailure || ''] }

{this.canTryAgain && ( )}
); }; renderBody() { return this.state.deviceFailure ? this.renderFailure() : this.props.children; } renderPrompt() { const {style} = this.props; return (
{this.renderBody()}
); } render() { const {isSupported} = this.state; // if we are still waiting for the browser to tell us if we can do u2f this // will be null. if (isSupported === null) { return null; } if (!isSupported) { return this.renderUnsupported(); } return this.renderPrompt(); } } export default withOrganization(U2fInterface);