Skip to content

Add recaptcha implementation #3166

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 5, 2020
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions packages-exp/auth-exp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ export { reload } from './core/user/reload';

// model
export { Operation as ActionCodeOperationType } from './model/action_code_info';

// platform-browser/recaptcha
export { RecaptchaVerifier } from './platform_browser/recaptcha/recaptcha_verifier';
22 changes: 22 additions & 0 deletions packages-exp/auth-exp/src/platform_browser/auth_window.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @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.
*/

/** Extend the Window type to include arbitrary keys (to avoid use of `any`) */
export interface AuthWindow extends Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
51 changes: 51 additions & 0 deletions packages-exp/auth-exp/src/platform_browser/load_js.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @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 { expect, use } from 'chai';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';

import { _generateCallbackName, _loadJS } from './load_js';

use(sinonChai);

describe('platform-browser/load_js', () => {
afterEach(() => sinon.restore());

describe('_generateCallbackName', () => {
it('generates a callback with a prefix and a number', () => {
expect(_generateCallbackName('foo')).to.match(/__foo\d+/);
});
});

describe('_loadJS', () => {
it('sets the appropriate properties', () => {
const el = document.createElement('script');
sinon.stub(el); // Prevent actually setting the src attribute
sinon.stub(document, 'createElement').returns(el);

// eslint-disable-next-line @typescript-eslint/no-floating-promises
_loadJS('http://localhost/url');
expect(el.setAttribute).to.have.been.calledWith(
'src',
'http://localhost/url'
);
expect(el.type).to.eq('text/javascript');
expect(el.charset).to.eq('UTF-8');
});
});
});
37 changes: 37 additions & 0 deletions packages-exp/auth-exp/src/platform_browser/load_js.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @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.
*/

function getScriptParentElement(): HTMLDocument | HTMLHeadElement {
return document.getElementsByTagName('head')?.[0] ?? document;
}

export function _loadJS(url: string): Promise<Event> {
// TODO: consider adding timeout support & cancellation
return new Promise((resolve, reject) => {
const el = document.createElement('script');
el.setAttribute('src', url);
el.onload = resolve;
el.onerror = reject;
el.type = 'text/javascript';
el.charset = 'UTF-8';
getScriptParentElement().appendChild(el);
});
}

export function _generateCallbackName(prefix: string): string {
return `__${prefix}${Math.floor(Math.random() * 1000000)}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @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.
*/

export interface Parameters {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}

export interface Recaptcha {
render: (container: HTMLElement, parameters: Parameters) => number;
getResponse: (id: number) => string;
execute: (id: number) => unknown;
reset: (id: number) => unknown;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* @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 { expect, use } from 'chai';
import * as chaiAsPromised from 'chai-as-promised';
import * as sinon from 'sinon';
import * as sinonChai from 'sinon-chai';

import { FirebaseError } from '@firebase/util';

import { testAuth } from '../../../test/mock_auth';
import { Auth } from '../../model/auth';
import { AuthWindow } from '../auth_window';
import * as jsHelpers from '../load_js';
import {
_JSLOAD_CALLBACK,
MOCK_RECAPTCHA_LOADER,
ReCaptchaLoader,
ReCaptchaLoaderImpl
} from './recaptcha_loader';
import { MockReCaptcha } from './recaptcha_mock';

const WINDOW: AuthWindow = window;

use(chaiAsPromised);
use(sinonChai);

describe('platform-browser/recaptcha/recaptcha_loader', () => {
let auth: Auth;

beforeEach(async () => {
auth = await testAuth();
});

afterEach(() => {
sinon.restore();
delete WINDOW.grecaptcha;
});

describe('MockLoader', () => {
it('returns a MockRecaptcha instance', async () => {
expect(await MOCK_RECAPTCHA_LOADER.load(auth)).to.be.instanceOf(
MockReCaptcha
);
});
});

describe('RealLoader', () => {
let triggerNetworkTimeout: () => void;
let jsLoader: { resolve: () => void; reject: () => void };
let loader: ReCaptchaLoader;
const networkTimeoutId = 123;

beforeEach(() => {
sinon.stub(window, 'setTimeout').callsFake(cb => {
triggerNetworkTimeout = () => cb();
// For some bizarre reason setTimeout always get shoehorned into NodeJS.Timeout,
// which is flat-wrong. This is the easiest way to fix it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return networkTimeoutId as any;
});

sinon.stub(jsHelpers, '_loadJS').callsFake(() => {
return new Promise((resolve, reject) => {
jsLoader = { resolve, reject };
});
});

loader = new ReCaptchaLoaderImpl();
});

context('network timeout / errors', () => {
it('rejects if the network times out', async () => {
const promise = loader.load(auth);
triggerNetworkTimeout();
await expect(promise).to.be.rejectedWith(
FirebaseError,
'Firebase: A network AuthError (such as timeout, interrupted connection or unreachable host) has occurred. (auth/network-request-failed).'
);
});

it('rejects with an internal error if the loadJS call fails', async () => {
const promise = loader.load(auth);
jsLoader.reject();
await expect(promise).to.be.rejectedWith(
FirebaseError,
'Firebase: An internal AuthError has occurred. (auth/internal-error).'
);
});
});

context('on js load callback', () => {
function spoofJsLoad(): void {
WINDOW[_JSLOAD_CALLBACK]();
}

it('clears the network timeout', () => {
sinon.spy(WINDOW, 'clearTimeout');
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loader.load(auth);
spoofJsLoad();
expect(WINDOW.clearTimeout).to.have.been.calledWith(networkTimeoutId);
});

it('rejects if the grecaptcha object is not on the window', async () => {
const promise = loader.load(auth);
spoofJsLoad();
await expect(promise).to.be.rejectedWith(
FirebaseError,
'Firebase: An internal AuthError has occurred. (auth/internal-error).'
);
});

it('overwrites the render method', async () => {
const promise = loader.load(auth);
const oldRenderMethod = (): string => 'foo';
WINDOW.grecaptcha = { render: oldRenderMethod };
spoofJsLoad();
expect((await promise).render).not.to.eq(oldRenderMethod);
});

it('returns immediately if the new language code matches the old', async () => {
const promise = loader.load(auth);
WINDOW.grecaptcha = { render: (): string => 'foo' };
spoofJsLoad();
await promise;
// Notice no call to spoofJsLoad..
expect(await loader.load(auth)).to.eq(WINDOW.grecaptcha);
});

it('returns immediately if grecaptcha is already set on window', async () => {
WINDOW.grecaptcha = { render: (): string => 'foo' };
const loader = new ReCaptchaLoaderImpl();
expect(await loader.load(auth)).to.eq(WINDOW.grecaptcha);
});
});
});
});
Loading