quodolores
Version:
Monorepo for the Firebase JavaScript SDK
302 lines (268 loc) • 8.88 kB
text/typescript
/**
* @license
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Auth } from '../../model/public_types';
import { getRecaptchaParams } from '../../api/authentication/recaptcha';
import { _castAuth } from '../../core/auth/auth_impl';
import { AuthErrorCode } from '../../core/errors';
import { _assert } from '../../core/util/assert';
import { _isHttpOrHttps } from '../../core/util/location';
import { ApplicationVerifierInternal } from '../../model/application_verifier';
import { AuthInternal } from '../../model/auth';
import { _window } from '../auth_window';
import { _isWorker } from '../util/worker';
import { Parameters, Recaptcha } from './recaptcha';
import {
MockReCaptchaLoaderImpl,
ReCaptchaLoader,
ReCaptchaLoaderImpl
} from './recaptcha_loader';
export const RECAPTCHA_VERIFIER_TYPE = 'recaptcha';
const DEFAULT_PARAMS: Parameters = {
theme: 'light',
type: 'image'
};
type TokenCallback = (token: string) => void;
/**
* An {@link https://www.google.com/recaptcha/ | reCAPTCHA}-based application verifier.
*
* @public
*/
export class RecaptchaVerifier implements ApplicationVerifierInternal {
/**
* The application verifier type.
*
* @remarks
* For a reCAPTCHA verifier, this is 'recaptcha'.
*/
readonly type = RECAPTCHA_VERIFIER_TYPE;
private destroyed = false;
private widgetId: number | null = null;
private readonly container: HTMLElement;
private readonly isInvisible: boolean;
private readonly tokenChangeListeners = new Set<TokenCallback>();
private renderPromise: Promise<number> | null = null;
private readonly auth: AuthInternal;
/** @internal */
readonly _recaptchaLoader: ReCaptchaLoader;
private recaptcha: Recaptcha | null = null;
/**
*
* @param containerOrId - The reCAPTCHA container parameter.
*
* @remarks
* This has different meaning depending on whether the reCAPTCHA is hidden or visible. For a
* visible reCAPTCHA the container must be empty. If a string is used, it has to correspond to
* an element ID. The corresponding element must also must be in the DOM at the time of
* initialization.
*
* @param parameters - The optional reCAPTCHA parameters.
*
* @remarks
* Check the reCAPTCHA docs for a comprehensive list. All parameters are accepted except for
* the sitekey. Firebase Auth backend provisions a reCAPTCHA for each project and will
* configure this upon rendering. For an invisible reCAPTCHA, a size key must have the value
* 'invisible'.
*
* @param authExtern - The corresponding Firebase Auth instance.
*
* @remarks
* If none is provided, the default Firebase Auth instance is used. A Firebase Auth instance
* must be initialized with an API key, otherwise an error will be thrown.
*/
constructor(
containerOrId: HTMLElement | string,
private readonly parameters: Parameters = {
...DEFAULT_PARAMS
},
authExtern: Auth
) {
this.auth = _castAuth(authExtern);
this.isInvisible = this.parameters.size === 'invisible';
_assert(
typeof document !== 'undefined',
this.auth,
AuthErrorCode.OPERATION_NOT_SUPPORTED
);
const container =
typeof containerOrId === 'string'
? document.getElementById(containerOrId)
: containerOrId;
_assert(container, this.auth, AuthErrorCode.ARGUMENT_ERROR);
this.container = container;
this.parameters.callback = this.makeTokenCallback(this.parameters.callback);
this._recaptchaLoader = this.auth.settings.appVerificationDisabledForTesting
? new MockReCaptchaLoaderImpl()
: new ReCaptchaLoaderImpl();
this.validateStartingState();
// TODO: Figure out if sdk version is needed
}
/**
* Waits for the user to solve the reCAPTCHA and resolves with the reCAPTCHA token.
*
* @returns A Promise for the reCAPTCHA token.
*/
async verify(): Promise<string> {
this.assertNotDestroyed();
const id = await this.render();
const recaptcha = this.getAssertedRecaptcha();
const response = recaptcha.getResponse(id);
if (response) {
return response;
}
return new Promise<string>(resolve => {
const tokenChange = (token: string): void => {
if (!token) {
return; // Ignore token expirations.
}
this.tokenChangeListeners.delete(tokenChange);
resolve(token);
};
this.tokenChangeListeners.add(tokenChange);
if (this.isInvisible) {
recaptcha.execute(id);
}
});
}
/**
* Renders the reCAPTCHA widget on the page.
*
* @returns A Promise that resolves with the reCAPTCHA widget ID.
*/
render(): Promise<number> {
try {
this.assertNotDestroyed();
} catch (e) {
// This method returns a promise. Since it's not async (we want to return the
// _same_ promise if rendering is still occurring), the API surface should
// reject with the error rather than just throw
return Promise.reject(e);
}
if (this.renderPromise) {
return this.renderPromise;
}
this.renderPromise = this.makeRenderPromise().catch(e => {
this.renderPromise = null;
throw e;
});
return this.renderPromise;
}
/** @internal */
_reset(): void {
this.assertNotDestroyed();
if (this.widgetId !== null) {
this.getAssertedRecaptcha().reset(this.widgetId);
}
}
/**
* Clears the reCAPTCHA widget from the page and destroys the instance.
*/
clear(): void {
this.assertNotDestroyed();
this.destroyed = true;
this._recaptchaLoader.clearedOneInstance();
if (!this.isInvisible) {
this.container.childNodes.forEach(node => {
this.container.removeChild(node);
});
}
}
private validateStartingState(): void {
_assert(!this.parameters.sitekey, this.auth, AuthErrorCode.ARGUMENT_ERROR);
_assert(
this.isInvisible || !this.container.hasChildNodes(),
this.auth,
AuthErrorCode.ARGUMENT_ERROR
);
_assert(
typeof document !== 'undefined',
this.auth,
AuthErrorCode.OPERATION_NOT_SUPPORTED
);
}
private makeTokenCallback(
existing: TokenCallback | string | undefined
): TokenCallback {
return token => {
this.tokenChangeListeners.forEach(listener => listener(token));
if (typeof existing === 'function') {
existing(token);
} else if (typeof existing === 'string') {
const globalFunc = _window()[existing];
if (typeof globalFunc === 'function') {
globalFunc(token);
}
}
};
}
private assertNotDestroyed(): void {
_assert(!this.destroyed, this.auth, AuthErrorCode.INTERNAL_ERROR);
}
private async makeRenderPromise(): Promise<number> {
await this.init();
if (!this.widgetId) {
let container = this.container;
if (!this.isInvisible) {
const guaranteedEmpty = document.createElement('div');
container.appendChild(guaranteedEmpty);
container = guaranteedEmpty;
}
this.widgetId = this.getAssertedRecaptcha().render(
container,
this.parameters
);
}
return this.widgetId;
}
private async init(): Promise<void> {
_assert(
_isHttpOrHttps() && !_isWorker(),
this.auth,
AuthErrorCode.INTERNAL_ERROR
);
await domReady();
this.recaptcha = await this._recaptchaLoader.load(
this.auth,
this.auth.languageCode || undefined
);
const siteKey = await getRecaptchaParams(this.auth);
_assert(siteKey, this.auth, AuthErrorCode.INTERNAL_ERROR);
this.parameters.sitekey = siteKey;
}
private getAssertedRecaptcha(): Recaptcha {
_assert(this.recaptcha, this.auth, AuthErrorCode.INTERNAL_ERROR);
return this.recaptcha;
}
}
function domReady(): Promise<void> {
let resolver: (() => void) | null = null;
return new Promise<void>(resolve => {
if (document.readyState === 'complete') {
resolve();
return;
}
// Document not ready, wait for load before resolving.
// Save resolver, so we can remove listener in case it was externally
// cancelled.
resolver = () => resolve();
window.addEventListener('load', resolver);
}).catch(e => {
if (resolver) {
window.removeEventListener('load', resolver);
}
throw e;
});
}