diff --git a/common/api-review/dummy-exp.api.md b/common/api-review/dummy-exp.api.md new file mode 100644 index 00000000000..e604672b2bc --- /dev/null +++ b/common/api-review/dummy-exp.api.md @@ -0,0 +1,22 @@ +## API Report File for "@firebase/dummy-exp" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export function bar(): string; + +// @public (undocumented) +export function bar1(): string; + +// @public (undocumented) +export function bar2(): string; + +// @public (undocumented) +export function foo1(): string; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/package.json b/package.json index 7c5b3a199d6..563e8c30074 100644 --- a/package.json +++ b/package.json @@ -137,11 +137,13 @@ "typescript": "3.8.3", "watch": "1.0.2", "webpack": "4.43.0", - "yargs": "15.3.1" + "yargs": "15.3.1", + "@rollup/plugin-commonjs": "13.0.0", + "@rollup/plugin-node-resolve": "8.1.0" }, "husky": { "hooks": { "pre-commit": "node tools/gitHooks/precommit.js" } } -} +} \ No newline at end of file diff --git a/packages-exp/auth-compat-exp/.eslintrc.js b/packages-exp/auth-compat-exp/.eslintrc.js new file mode 100644 index 00000000000..ca80aa0f69a --- /dev/null +++ b/packages-exp/auth-compat-exp/.eslintrc.js @@ -0,0 +1,26 @@ +/** + * @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. + */ + +module.exports = { + extends: '../../config/.eslintrc.js', + parserOptions: { + project: 'tsconfig.json', + // to make vscode-eslint work with monorepo + // https://github.com/typescript-eslint/typescript-eslint/issues/251#issuecomment-463943250 + tsconfigRootDir: __dirname + } +}; diff --git a/packages-exp/auth-compat-exp/README.md b/packages-exp/auth-compat-exp/README.md new file mode 100644 index 00000000000..ed7faf71ab8 --- /dev/null +++ b/packages-exp/auth-compat-exp/README.md @@ -0,0 +1,5 @@ +# @firebase/auth-compat-exp + +This is a compatability layer to for the Firebase Authentication SDK + +**This package is not intended for direct usage, and should only be used via the officially supported [firebase](https://www.npmjs.com/package/firebase) package.** \ No newline at end of file diff --git a/packages-exp/auth-compat-exp/index.node.ts b/packages-exp/auth-compat-exp/index.node.ts new file mode 100644 index 00000000000..9643aac0b0c --- /dev/null +++ b/packages-exp/auth-compat-exp/index.node.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2017 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. + */ + +/** + * This is the file that people using Node.js will actually import. You should + * only include this file if you have something specific about your + * implementation that mandates having a separate entrypoint. Otherwise you can + * just use index.ts + */ + +import { testFxn } from './src'; + +testFxn(); diff --git a/packages-exp/auth-compat-exp/index.rn.ts b/packages-exp/auth-compat-exp/index.rn.ts new file mode 100644 index 00000000000..ac057efb36e --- /dev/null +++ b/packages-exp/auth-compat-exp/index.rn.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2017 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. + */ + +/** + * This is the file that people using React Native will actually import. You + * should only include this file if you have something specific about your + * implementation that mandates having a separate entrypoint. Otherwise you can + * just use index.ts + */ + +import { AsyncStorage } from 'react-native'; +import { ReactNativePersistence } from '@firebase/auth-exp/src/core/persistence/react_native'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const reactNativeLocalPersistence = new ReactNativePersistence(AsyncStorage); diff --git a/packages-exp/auth-compat-exp/index.ts b/packages-exp/auth-compat-exp/index.ts new file mode 100644 index 00000000000..253cd645fb0 --- /dev/null +++ b/packages-exp/auth-compat-exp/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2017 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 { testFxn } from './src'; + +testFxn(); diff --git a/packages-exp/auth-compat-exp/karma.conf.js b/packages-exp/auth-compat-exp/karma.conf.js new file mode 100644 index 00000000000..fe4ace4252f --- /dev/null +++ b/packages-exp/auth-compat-exp/karma.conf.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019 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. + */ + +const karmaBase = require('../../config/karma.base'); + +const files = ['src/**/*.test.ts']; + +module.exports = function(config) { + const karmaConfig = Object.assign({}, karmaBase, { + // files to load into karma + files: files, + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'] + }); + + config.set(karmaConfig); +}; + +module.exports.files = files; diff --git a/packages-exp/auth-compat-exp/package.json b/packages-exp/auth-compat-exp/package.json new file mode 100644 index 00000000000..d0110fcb6dc --- /dev/null +++ b/packages-exp/auth-compat-exp/package.json @@ -0,0 +1,59 @@ +{ + "name": "@firebase/auth-compat-exp", + "version": "0.1.0", + "private": true, + "description": "FirebaseAuth compatibility package that uses API style compatible with Firebase@7 and prior versions", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.node.cjs.js", + "browser": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", + "react-native": "dist/index.rn.cjs.js", + "files": [ + "dist" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c", + "build:deps": "lerna run --scope @firebase/'{app,auth-compat-exp}' --include-dependencies build", + "dev": "rollup -c -w", + "test": "yarn type-check && run-p lint test:browser test:node", + "test:browser": "karma start --single-run", + "test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.* --opts ../../config/mocha.node.opts", + "type-check": "tsc -p . --noEmit", + "prepare": "yarn build" + }, + "peerDependencies": { + "@firebase/app-exp": "0.x", + "@firebase/app-types-exp": "0.x", + "@firebase/auth-exp": "0.x", + "@firebase/auth-types-exp": "0.x" + }, + "dependencies": { + "tslib": "1.11.1" + }, + "license": "Apache-2.0", + "devDependencies": { + "rollup": "1.32.1", + "rollup-plugin-json": "4.0.0", + "rollup-plugin-replace": "2.2.0", + "rollup-plugin-typescript2": "0.26.0", + "typescript": "3.8.3" + }, + "repository": { + "directory": "packages-exp/auth-compat-exp", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "dist/index.d.ts", + "nyc": { + "extension": [ + ".ts" + ], + "reportDir": "./coverage/node" + } +} diff --git a/packages-exp/auth-compat-exp/react-native.d.ts b/packages-exp/auth-compat-exp/react-native.d.ts new file mode 100644 index 00000000000..3f429a55cbe --- /dev/null +++ b/packages-exp/auth-compat-exp/react-native.d.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019 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. + */ + +/** + * Basic stub for what is expected from the package 'react-native'. + * + * This should be a subset `@types/react-native` which cannot be installed + * because it has conflicting definitions with typescript/dom. If included in + * deps, yarn will attempt to install this in the root directory of the + * monolithic repository which will then be included in the base `tsconfig.json` + * via `typeroots` and then break every other package in this repo. + */ + +declare module 'react-native' { + interface ReactNativeAsyncStorage { + setItem(key: string, value: string): Promise; + getItem(key: string): Promise; + removeItem(key: string): Promise; + } + export const AsyncStorage: ReactNativeAsyncStorage; +} diff --git a/packages-exp/auth-compat-exp/rollup.config.js b/packages-exp/auth-compat-exp/rollup.config.js new file mode 100644 index 00000000000..a4547927a71 --- /dev/null +++ b/packages-exp/auth-compat-exp/rollup.config.js @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2019 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 typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; +import pkg from './package.json'; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ + /** + * Browser Builds + */ + { + input: 'index.ts', + output: [ + { file: pkg.browser, format: 'cjs', sourcemap: true }, + { file: pkg.module, format: 'es', sourcemap: true } + ], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + }, + /** + * Node.js Build + */ + { + input: 'index.node.ts', + output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + }, + /** + * React Native Builds + */ + { + input: 'index.rn.ts', + output: [{ file: pkg['react-native'], format: 'cjs', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => + [...deps, 'react-native'].some( + dep => id === dep || id.startsWith(`${dep}/`) + ) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }) +]; + +const es2017Builds = [ + /** + * Browser Builds + */ + { + input: 'index.ts', + output: { + file: pkg.esm2017, + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/auth-compat-exp/src/index.test.ts b/packages-exp/auth-compat-exp/src/index.test.ts new file mode 100644 index 00000000000..3dcd596c050 --- /dev/null +++ b/packages-exp/auth-compat-exp/src/index.test.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2017 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 } from 'chai'; +import { testFxn } from '../src'; + +describe('Simple test', () => { + it('Should skip this test'); + it('Should test this fxn', () => { + expect(testFxn()).to.equal(42); + }); + it('Should test this async thing', async () => { + // Do some async assertions, you can use `await` syntax if it helps + const val = await Promise.resolve(42); + expect(val).to.equal(42); + }); +}); diff --git a/packages-exp/auth-compat-exp/src/index.ts b/packages-exp/auth-compat-exp/src/index.ts new file mode 100644 index 00000000000..9dd3eee5c8d --- /dev/null +++ b/packages-exp/auth-compat-exp/src/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2017 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 function testFxn(): number { + console.log('hi'); + return 42; +} diff --git a/packages-exp/auth-compat-exp/tsconfig.json b/packages-exp/auth-compat-exp/tsconfig.json new file mode 100644 index 00000000000..a06ed9a374c --- /dev/null +++ b/packages-exp/auth-compat-exp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "exclude": [ + "dist/**/*" + ] +} \ No newline at end of file diff --git a/packages-exp/auth-exp/.eslintrc.js b/packages-exp/auth-exp/.eslintrc.js new file mode 100644 index 00000000000..ca80aa0f69a --- /dev/null +++ b/packages-exp/auth-exp/.eslintrc.js @@ -0,0 +1,26 @@ +/** + * @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. + */ + +module.exports = { + extends: '../../config/.eslintrc.js', + parserOptions: { + project: 'tsconfig.json', + // to make vscode-eslint work with monorepo + // https://github.com/typescript-eslint/typescript-eslint/issues/251#issuecomment-463943250 + tsconfigRootDir: __dirname + } +}; diff --git a/packages-exp/auth-exp/README.md b/packages-exp/auth-exp/README.md new file mode 100644 index 00000000000..6b08dc72029 --- /dev/null +++ b/packages-exp/auth-exp/README.md @@ -0,0 +1,5 @@ +# @firebase/auth-exp + +This is the Firebase Authentication component of the Firebase JS SDK. + +**This package is not intended for direct usage, and should only be used via the officially supported [firebase](https://www.npmjs.com/package/firebase) package.** \ No newline at end of file diff --git a/packages-exp/auth-exp/index.node.ts b/packages-exp/auth-exp/index.node.ts new file mode 100644 index 00000000000..8a85793e0f4 --- /dev/null +++ b/packages-exp/auth-exp/index.node.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2017 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. + */ + +/** + * This is the file that people using Node.js will actually import. You should + * only include this file if you have something specific about your + * implementation that mandates having a separate entrypoint. Otherwise you can + * just use index.ts + */ diff --git a/packages-exp/auth-exp/karma.conf.js b/packages-exp/auth-exp/karma.conf.js new file mode 100644 index 00000000000..6e8b47df149 --- /dev/null +++ b/packages-exp/auth-exp/karma.conf.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019 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. + */ + +const karmaBase = require('../../config/karma.base'); + +const files = ['src/**/*.test.ts', 'test/**/*.test.ts']; + +module.exports = function(config) { + const karmaConfig = Object.assign({}, karmaBase, { + // files to load into karma + files: files, + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'] + }); + + config.set(karmaConfig); +}; + +module.exports.files = files; diff --git a/packages-exp/auth-exp/package.json b/packages-exp/auth-exp/package.json new file mode 100644 index 00000000000..1cfe2124ab8 --- /dev/null +++ b/packages-exp/auth-exp/package.json @@ -0,0 +1,61 @@ +{ + "name": "@firebase/auth-exp", + "version": "0.1.0", + "private": true, + "description": "TODO", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.node.cjs.js", + "browser": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", + "files": [ + "dist" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c", + "build:deps": "lerna run --scope @firebase/'{app-exp,auth-exp}' --include-dependencies build", + "dev": "rollup -c -w", + "test": "yarn type-check && run-p lint test:browser", + "test:browser": "karma start --single-run", + "test:browser:debug": "karma start", + "test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.* --opts ../../config/mocha.node.opts", + "type-check": "tsc -p . --noEmit", + "prepare": "yarn build" + }, + "peerDependencies": { + "@firebase/app-exp": "0.x", + "@firebase/app-types-exp": "0.x", + "@firebase/auth-types-exp": "0.x" + }, + "dependencies": { + "@firebase/logger": "^0.2.2", + "@firebase/util": "^0.2.44", + "tslib": "1.11.1" + }, + "license": "Apache-2.0", + "devDependencies": { + "@rollup/plugin-strip": "^1.3.2", + "@firebase/app-exp": "0.x", + "rollup": "1.32.1", + "rollup-plugin-json": "4.0.0", + "rollup-plugin-replace": "2.2.0", + "rollup-plugin-typescript2": "0.26.0" + }, + "repository": { + "directory": "packages-exp/auth-exp", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "dist/src/index.d.ts", + "nyc": { + "extension": [ + ".ts" + ], + "reportDir": "./coverage/node" + } +} \ No newline at end of file diff --git a/packages-exp/auth-exp/rollup.config.js b/packages-exp/auth-exp/rollup.config.js new file mode 100644 index 00000000000..611ccc2b7e7 --- /dev/null +++ b/packages-exp/auth-exp/rollup.config.js @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2019 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 strip from '@rollup/plugin-strip'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; +import pkg from './package.json'; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * Common plugins for all builds + */ +const commonPlugins = [ + strip({ + functions: ['debugAssert.*'] + }) +]; + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + ...commonPlugins, + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: [{ file: pkg.module, format: 'es', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + }, + /** + * Node.js Build + */ + { + input: 'index.node.ts', + output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + ...commonPlugins, + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }) +]; + +const es2017Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: { + file: pkg.esm2017, + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/auth-exp/src/api/account_management/account.test.ts b/packages-exp/auth-exp/src/api/account_management/account.test.ts new file mode 100644 index 00000000000..7ed42416f9a --- /dev/null +++ b/packages-exp/auth-exp/src/api/account_management/account.test.ts @@ -0,0 +1,204 @@ +/** + * @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 { ProviderId } from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { deleteAccount, deleteLinkedAccounts, getAccountInfo } from './account'; + +use(chaiAsPromised); + +describe('api/account_management/deleteAccount', () => { + const request = { + idToken: 'id-token' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.DELETE_ACCOUNT, {}); + + await deleteAccount(auth, request); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.DELETE_ACCOUNT, + { + error: { + code: 400, + message: ServerError.INVALID_ID_TOKEN, + errors: [ + { + message: ServerError.INVALID_ID_TOKEN + } + ] + } + }, + 400 + ); + + await expect(deleteAccount(auth, request)).to.be.rejectedWith( + FirebaseError, + "Firebase: This user's credential isn't valid for this project. This can happen if the user's token has been tampered with, or if the user isn't for the project associated with this API key. (auth/invalid-user-token)." + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/account_management/deleteLinkedAccounts', () => { + const request = { + idToken: 'id-token', + deleteProvider: [ProviderId.GOOGLE] + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + providerUserInfo: [ + { + providerId: ProviderId.GOOGLE, + email: 'test@foo.com' + } + ] + }); + + const response = await deleteLinkedAccounts(auth, request); + expect(response.providerUserInfo[0].providerId).to.eq('google.com'); + expect(response.providerUserInfo[0].email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SET_ACCOUNT_INFO, + { + error: { + code: 400, + message: ServerError.INVALID_PROVIDER_ID, + errors: [ + { + message: ServerError.INVALID_PROVIDER_ID + } + ] + } + }, + 400 + ); + + await expect(deleteLinkedAccounts(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The specified provider ID is invalid. (auth/invalid-provider-id).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/account_management/getAccountInfo', () => { + const request = { + idToken: 'id-token' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [ + { + displayName: 'my-name', + email: 'test@foo.com' + } + ] + }); + + const response = await getAccountInfo(auth, request); + expect(response.users[0].displayName).to.eq('my-name'); + expect(response.users[0].email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.GET_ACCOUNT_INFO, + { + error: { + code: 400, + message: ServerError.INVALID_ID_TOKEN, + errors: [ + { + message: ServerError.INVALID_ID_TOKEN + } + ] + } + }, + 400 + ); + + await expect(getAccountInfo(auth, request)).to.be.rejectedWith( + FirebaseError, + "Firebase: This user's credential isn't valid for this project. This can happen if the user's token has been tampered with, or if the user isn't for the project associated with this API key. (auth/invalid-user-token)." + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/account_management/account.ts b/packages-exp/auth-exp/src/api/account_management/account.ts new file mode 100644 index 00000000000..fe47a27ad77 --- /dev/null +++ b/packages-exp/auth-exp/src/api/account_management/account.ts @@ -0,0 +1,99 @@ +/** + * @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 { Endpoint, HttpMethod, _performApiRequest } from '../'; +import { Auth } from '../../model/auth'; +import { APIMFAInfo } from '../../model/id_token'; + +export interface DeleteAccountRequest { + idToken: string; +} + +export async function deleteAccount( + auth: Auth, + request: DeleteAccountRequest +): Promise { + return _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.DELETE_ACCOUNT, + request + ); +} + +export interface ProviderUserInfo { + rawId?: string; + providerId?: string; + email?: string; + displayName?: string; + photoUrl?: string; + phoneNumber?: string; +} + +export interface DeleteLinkedAccountsRequest { + idToken: string; + deleteProvider: string[]; +} + +export interface DeleteLinkedAccountsResponse { + providerUserInfo: ProviderUserInfo[]; +} + +export async function deleteLinkedAccounts( + auth: Auth, + request: DeleteLinkedAccountsRequest +): Promise { + return _performApiRequest< + DeleteLinkedAccountsRequest, + DeleteLinkedAccountsResponse + >(auth, HttpMethod.POST, Endpoint.SET_ACCOUNT_INFO, request); +} + +export interface APIUserInfo { + localId?: string; + displayName?: string; + photoUrl?: string; + email?: string; + emailVerified?: boolean; + phoneNumber?: string; + lastLoginAt?: number; + createdAt?: number; + tenantId?: string; + passwordHash?: string; + providerUserInfo?: ProviderUserInfo[]; + mfaInfo?: APIMFAInfo[]; +} + +export interface GetAccountInfoRequest { + idToken: string; +} + +export interface GetAccountInfoResponse { + users: APIUserInfo[]; +} + +export async function getAccountInfo( + auth: Auth, + request: GetAccountInfoRequest +): Promise { + return _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.GET_ACCOUNT_INFO, + request + ); +} diff --git a/packages-exp/auth-exp/src/api/account_management/email_and_password.test.ts b/packages-exp/auth-exp/src/api/account_management/email_and_password.test.ts new file mode 100644 index 00000000000..8416e85c65c --- /dev/null +++ b/packages-exp/auth-exp/src/api/account_management/email_and_password.test.ts @@ -0,0 +1,199 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { + applyActionCode, + resetPassword, + updateEmailPassword +} from './email_and_password'; + +use(chaiAsPromised); + +describe('api/account_management/resetPassword', () => { + const request = { + oobCode: 'oob-code', + newPassword: 'new-password' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { + email: 'test@foo.com' + }); + + const response = await resetPassword(auth, request); + expect(response.email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.RESET_PASSWORD, + { + error: { + code: 400, + message: ServerError.RESET_PASSWORD_EXCEED_LIMIT, + errors: [ + { + message: ServerError.RESET_PASSWORD_EXCEED_LIMIT + } + ] + } + }, + 400 + ); + + await expect(resetPassword(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: We have blocked all requests from this device due to unusual activity. Try again later. (auth/too-many-requests).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/account_management/updateEmailPassword', () => { + const request = { + idToken: 'id-token', + returnSecureToken: true, + email: 'test@foo.com', + password: 'new-password' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'id-token' + }); + + const response = await updateEmailPassword(auth, request); + expect(response.idToken).to.eq('id-token'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SET_ACCOUNT_INFO, + { + error: { + code: 400, + message: ServerError.INVALID_EMAIL, + errors: [ + { + message: ServerError.INVALID_EMAIL + } + ] + } + }, + 400 + ); + + await expect(updateEmailPassword(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is badly formatted. (auth/invalid-email).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/account_management/applyActionCode', () => { + const request = { + oobCode: 'oob-code' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + + const response = await applyActionCode(auth, request); + expect(response).to.be.empty; + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SET_ACCOUNT_INFO, + { + error: { + code: 400, + message: ServerError.INVALID_OOB_CODE, + errors: [ + { + message: ServerError.INVALID_OOB_CODE + } + ] + } + }, + 400 + ); + + await expect(applyActionCode(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The action code is invalid. This can happen if the code is malformed, expired, or has already been used. (auth/invalid-action-code).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/account_management/email_and_password.ts b/packages-exp/auth-exp/src/api/account_management/email_and_password.ts new file mode 100644 index 00000000000..63bf6518225 --- /dev/null +++ b/packages-exp/auth-exp/src/api/account_management/email_and_password.ts @@ -0,0 +1,81 @@ +/** + * @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 { Operation } from '@firebase/auth-types-exp'; + +import { Endpoint, HttpMethod, _performApiRequest } from '..'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; + +export interface ResetPasswordRequest { + oobCode: string; + newPassword?: string; +} + +export interface ResetPasswordResponse { + email: string; + newEmail?: string; + requestType?: Operation; +} + +export async function resetPassword( + auth: Auth, + request: ResetPasswordRequest +): Promise { + return _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.RESET_PASSWORD, + request + ); +} +export interface UpdateEmailPasswordRequest { + idToken: string; + returnSecureToken?: boolean; + email?: string; + password?: string; +} + +export interface UpdateEmailPasswordResponse extends IdTokenResponse {} + +export async function updateEmailPassword( + auth: Auth, + request: UpdateEmailPasswordRequest +): Promise { + return _performApiRequest< + UpdateEmailPasswordRequest, + UpdateEmailPasswordResponse + >(auth, HttpMethod.POST, Endpoint.SET_ACCOUNT_INFO, request); +} + +export interface ApplyActionCodeRequest { + oobCode: string; +} + +export interface ApplyActionCodeResponse {} + +export async function applyActionCode( + auth: Auth, + request: ApplyActionCodeRequest +): Promise { + return _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.SET_ACCOUNT_INFO, + request + ); +} diff --git a/packages-exp/auth-exp/src/api/account_management/mfa.test.ts b/packages-exp/auth-exp/src/api/account_management/mfa.test.ts new file mode 100644 index 00000000000..cb675010f28 --- /dev/null +++ b/packages-exp/auth-exp/src/api/account_management/mfa.test.ts @@ -0,0 +1,209 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { enrollPhoneMfa, startEnrollPhoneMfa, withdrawMfa } from './mfa'; + +use(chaiAsPromised); + +describe('api/account_management/startEnrollPhoneMfa', () => { + const request = { + idToken: 'id-token', + phoneEnrollmentInfo: { + phoneNumber: 'phone-number', + recaptchaToken: 'captcha-token' + } + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.START_PHONE_MFA_ENROLLMENT, { + phoneSessionInfo: { + sessionInfo: 'session-info' + } + }); + + const response = await startEnrollPhoneMfa(auth, request); + expect(response.phoneSessionInfo.sessionInfo).to.eq('session-info'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.START_PHONE_MFA_ENROLLMENT, + { + error: { + code: 400, + message: ServerError.INVALID_ID_TOKEN, + errors: [ + { + message: ServerError.INVALID_ID_TOKEN + } + ] + } + }, + 400 + ); + + await expect(startEnrollPhoneMfa(auth, request)).to.be.rejectedWith( + FirebaseError, + "Firebase: This user's credential isn't valid for this project. This can happen if the user's token has been tampered with, or if the user isn't for the project associated with this API key. (auth/invalid-user-token)." + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/account_management/enrollPhoneMfa', () => { + const request = { + phoneVerificationInfo: { + temporaryProof: 'temporary-proof', + phoneNumber: 'phone-number', + sessionInfo: 'session-info', + code: 'code' + } + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.FINALIZE_PHONE_MFA_ENROLLMENT, { + displayName: 'my-name', + idToken: 'id-token' + }); + + const response = await enrollPhoneMfa(auth, request); + expect(response.displayName).to.eq('my-name'); + expect(response.idToken).to.eq('id-token'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.FINALIZE_PHONE_MFA_ENROLLMENT, + { + error: { + code: 400, + message: ServerError.INVALID_SESSION_INFO, + errors: [ + { + message: ServerError.INVALID_SESSION_INFO + } + ] + } + }, + 400 + ); + + await expect(enrollPhoneMfa(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The verification ID used to create the phone auth credential is invalid. (auth/invalid-verification-id).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/account_management/withdrawMfa', () => { + const request = { + idToken: 'id-token', + mfaEnrollmentId: 'mfa-enrollment-id' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.WITHDRAW_MFA, { + displayName: 'my-name', + idToken: 'id-token' + }); + + const response = await withdrawMfa(auth, request); + expect(response.displayName).to.eq('my-name'); + expect(response.idToken).to.eq('id-token'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.WITHDRAW_MFA, + { + error: { + code: 400, + message: ServerError.INVALID_ID_TOKEN, + errors: [ + { + message: ServerError.INVALID_ID_TOKEN + } + ] + } + }, + 400 + ); + + await expect(withdrawMfa(auth, request)).to.be.rejectedWith( + FirebaseError, + "Firebase: This user's credential isn't valid for this project. This can happen if the user's token has been tampered with, or if the user isn't for the project associated with this API key. (auth/invalid-user-token)." + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/account_management/mfa.ts b/packages-exp/auth-exp/src/api/account_management/mfa.ts new file mode 100644 index 00000000000..30683212e84 --- /dev/null +++ b/packages-exp/auth-exp/src/api/account_management/mfa.ts @@ -0,0 +1,81 @@ +/** + * @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 { Endpoint, HttpMethod, _performApiRequest } from '..'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { SignInWithPhoneNumberRequest } from '../authentication/sms'; + +export interface StartPhoneMfaEnrollmentRequest { + idToken: string; + + phoneEnrollmentInfo: { + phoneNumber: string; + recaptchaToken: string; + }; +} + +export interface StartPhoneMfaEnrollmentResponse { + phoneSessionInfo: { + sessionInfo: string; + }; +} + +export function startEnrollPhoneMfa( + auth: Auth, + request: StartPhoneMfaEnrollmentRequest +): Promise { + return _performApiRequest< + StartPhoneMfaEnrollmentRequest, + StartPhoneMfaEnrollmentResponse + >(auth, HttpMethod.POST, Endpoint.START_PHONE_MFA_ENROLLMENT, request); +} + +export interface PhoneMfaEnrollmentRequest { + phoneVerificationInfo: SignInWithPhoneNumberRequest; +} + +export interface PhoneMfaEnrollmentResponse extends IdTokenResponse {} + +export function enrollPhoneMfa( + auth: Auth, + request: PhoneMfaEnrollmentRequest +): Promise { + return _performApiRequest< + PhoneMfaEnrollmentRequest, + PhoneMfaEnrollmentResponse + >(auth, HttpMethod.POST, Endpoint.FINALIZE_PHONE_MFA_ENROLLMENT, request); +} + +export interface WithdrawMfaRequest { + idToken: string; + mfaEnrollmentId: string; +} + +export interface WithdrawMfaResponse extends IdTokenResponse {} + +export function withdrawMfa( + auth: Auth, + request: WithdrawMfaRequest +): Promise { + return _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.WITHDRAW_MFA, + request + ); +} diff --git a/packages-exp/auth-exp/src/api/account_management/profile.test.ts b/packages-exp/auth-exp/src/api/account_management/profile.test.ts new file mode 100644 index 00000000000..fe0b0ffdc78 --- /dev/null +++ b/packages-exp/auth-exp/src/api/account_management/profile.test.ts @@ -0,0 +1,88 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { updateProfile } from './profile'; + +use(chaiAsPromised); + +describe('api/account_management/updateProfile', () => { + const request = { + idToken: 'my-token', + email: 'test@foo.com', + password: 'my-password' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + displayName: 'my-name', + email: 'test@foo.com' + }); + + const response = await updateProfile(auth, request); + expect(response.displayName).to.eq('my-name'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SET_ACCOUNT_INFO, + { + error: { + code: 400, + message: ServerError.EMAIL_EXISTS, + errors: [ + { + message: ServerError.EMAIL_EXISTS + } + ] + } + }, + 400 + ); + + await expect(updateProfile(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is already in use by another account. (auth/email-already-in-use).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/account_management/profile.ts b/packages-exp/auth-exp/src/api/account_management/profile.ts new file mode 100644 index 00000000000..ab84a3a5c37 --- /dev/null +++ b/packages-exp/auth-exp/src/api/account_management/profile.ts @@ -0,0 +1,43 @@ +/** + * @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 { Endpoint, HttpMethod, _performApiRequest } from '..'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; + +export interface UpdateProfileRequest { + idToken: string; + displayName?: string | null; + photoUrl?: string | null; +} + +export interface UpdateProfileResponse extends IdTokenResponse { + displayName?: string | null; + photoUrl?: string | null; +} + +export async function updateProfile( + auth: Auth, + request: UpdateProfileRequest +): Promise { + return _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.SET_ACCOUNT_INFO, + request + ); +} diff --git a/packages-exp/auth-exp/src/api/authentication/create_auth_uri.test.ts b/packages-exp/auth-exp/src/api/authentication/create_auth_uri.test.ts new file mode 100644 index 00000000000..ef53b584ecf --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/create_auth_uri.test.ts @@ -0,0 +1,86 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { createAuthUri } from './create_auth_uri'; + +use(chaiAsPromised); + +describe('api/authentication/createAuthUri', () => { + const request = { + identifier: 'my-id', + continueUri: 'example.com/redirectUri' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.CREATE_AUTH_URI, { + signinMethods: ['email'] + }); + + const response = await createAuthUri(auth, request); + expect(response.signinMethods).to.include('email'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.CREATE_AUTH_URI, + { + error: { + code: 400, + message: ServerError.INVALID_PROVIDER_ID, + errors: [ + { + message: ServerError.INVALID_PROVIDER_ID + } + ] + } + }, + 400 + ); + + await expect(createAuthUri(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The specified provider ID is invalid. (auth/invalid-provider-id).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/create_auth_uri.ts b/packages-exp/auth-exp/src/api/authentication/create_auth_uri.ts new file mode 100644 index 00000000000..3f1cee42c0d --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/create_auth_uri.ts @@ -0,0 +1,40 @@ +/** + * @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 { Endpoint, HttpMethod, _performApiRequest } from '..'; +import { Auth } from '../../model/auth'; + +export interface CreateAuthUriRequest { + identifier: string; + continueUri: string; +} + +export interface CreateAuthUriResponse { + signinMethods: string[]; +} + +export async function createAuthUri( + auth: Auth, + request: CreateAuthUriRequest +): Promise { + return _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.CREATE_AUTH_URI, + request + ); +} diff --git a/packages-exp/auth-exp/src/api/authentication/custom_token.test.ts b/packages-exp/auth-exp/src/api/authentication/custom_token.test.ts new file mode 100644 index 00000000000..491f7f6771e --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/custom_token.test.ts @@ -0,0 +1,92 @@ +/** + * @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 { ProviderId } from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { signInWithCustomToken } from './custom_token'; + +use(chaiAsPromised); + +describe('api/authentication/signInWithCustomToken', () => { + const request = { + token: 'my-token' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SIGN_IN_WITH_CUSTOM_TOKEN, { + providerId: ProviderId.CUSTOM, + idToken: 'id-token', + expiresIn: '1000', + localId: '1234' + }); + + const response = await signInWithCustomToken(auth, request); + expect(response.providerId).to.eq(ProviderId.CUSTOM); + expect(response.idToken).to.eq('id-token'); + expect(response.expiresIn).to.eq('1000'); + expect(response.localId).to.eq('1234'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_IN_WITH_CUSTOM_TOKEN, + { + error: { + code: 400, + message: ServerError.INVALID_CUSTOM_TOKEN, + errors: [ + { + message: ServerError.INVALID_CUSTOM_TOKEN + } + ] + } + }, + 400 + ); + + await expect(signInWithCustomToken(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The custom token format is incorrect. Please check the documentation. (auth/invalid-custom-token).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/custom_token.ts b/packages-exp/auth-exp/src/api/authentication/custom_token.ts new file mode 100644 index 00000000000..608deae2333 --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/custom_token.ts @@ -0,0 +1,36 @@ +/** + * @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 { Endpoint, HttpMethod, _performSignInRequest } from '..'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; + +export interface SignInWithCustomTokenRequest { + token: string; +} + +export interface SignInWithCustomTokenResponse extends IdTokenResponse {} + +export async function signInWithCustomToken( + auth: Auth, + request: SignInWithCustomTokenRequest +): Promise { + return _performSignInRequest< + SignInWithCustomTokenRequest, + SignInWithCustomTokenResponse + >(auth, HttpMethod.POST, Endpoint.SIGN_IN_WITH_CUSTOM_TOKEN, request); +} diff --git a/packages-exp/auth-exp/src/api/authentication/email_and_password.test.ts b/packages-exp/auth-exp/src/api/authentication/email_and_password.test.ts new file mode 100644 index 00000000000..7e31d822ca8 --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/email_and_password.test.ts @@ -0,0 +1,263 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { + EmailSignInRequest, + PasswordResetRequest, + sendEmailVerification, + sendPasswordResetEmail, + sendSignInLinkToEmail, + signInWithPassword, + VerifyEmailRequest +} from './email_and_password'; +import { Operation } from '@firebase/auth-types-exp'; + +use(chaiAsPromised); + +describe('api/authentication/signInWithPassword', () => { + const request = { + returnSecureToken: true, + email: 'test@foo.com', + password: 'my-password' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SIGN_IN_WITH_PASSWORD, { + displayName: 'my-name', + email: 'test@foo.com' + }); + + const response = await signInWithPassword(auth, request); + expect(response.displayName).to.eq('my-name'); + expect(response.email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_IN_WITH_PASSWORD, + { + error: { + code: 400, + message: ServerError.INVALID_PASSWORD, + errors: [ + { + message: ServerError.INVALID_PASSWORD + } + ] + } + }, + 400 + ); + + await expect(signInWithPassword(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The password is invalid or the user does not have a password. (auth/wrong-password).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/authentication/sendEmailVerification', () => { + const request: VerifyEmailRequest = { + requestType: Operation.VERIFY_EMAIL, + idToken: 'my-token' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email: 'test@foo.com' + }); + + const response = await sendEmailVerification(auth, request); + expect(response.email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SEND_OOB_CODE, + { + error: { + code: 400, + message: ServerError.INVALID_EMAIL, + errors: [ + { + message: ServerError.INVALID_EMAIL + } + ] + } + }, + 400 + ); + + await expect(sendEmailVerification(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is badly formatted. (auth/invalid-email).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/authentication/sendPasswordResetEmail', () => { + const request: PasswordResetRequest = { + requestType: Operation.PASSWORD_RESET, + email: 'test@foo.com' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email: 'test@foo.com' + }); + + const response = await sendPasswordResetEmail(auth, request); + expect(response.email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SEND_OOB_CODE, + { + error: { + code: 400, + message: ServerError.INVALID_EMAIL, + errors: [ + { + message: ServerError.INVALID_EMAIL + } + ] + } + }, + 400 + ); + + await expect(sendPasswordResetEmail(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is badly formatted. (auth/invalid-email).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/authentication/sendSignInLinkToEmail', () => { + const request: EmailSignInRequest = { + requestType: Operation.EMAIL_SIGNIN, + email: 'test@foo.com' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email: 'test@foo.com' + }); + + const response = await sendSignInLinkToEmail(auth, request); + expect(response.email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SEND_OOB_CODE, + { + error: { + code: 400, + message: ServerError.INVALID_EMAIL, + errors: [ + { + message: ServerError.INVALID_EMAIL + } + ] + } + }, + 400 + ); + + await expect(sendSignInLinkToEmail(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is badly formatted. (auth/invalid-email).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/email_and_password.ts b/packages-exp/auth-exp/src/api/authentication/email_and_password.ts new file mode 100644 index 00000000000..9f5cc095c3c --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/email_and_password.ts @@ -0,0 +1,120 @@ +/** + * @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 { Operation } from '@firebase/auth-types-exp'; + +import { + Endpoint, + HttpMethod, + _performApiRequest, + _performSignInRequest +} from '..'; +import { Auth } from '../../model/auth'; +import { IdToken, IdTokenResponse } from '../../model/id_token'; + +export interface SignInWithPasswordRequest { + returnSecureToken?: boolean; + email: string; + password: string; +} + +export interface SignInWithPasswordResponse extends IdTokenResponse { + email: string; + displayName: string; +} + +export async function signInWithPassword( + auth: Auth, + request: SignInWithPasswordRequest +): Promise { + return _performSignInRequest< + SignInWithPasswordRequest, + SignInWithPasswordResponse + >(auth, HttpMethod.POST, Endpoint.SIGN_IN_WITH_PASSWORD, request); +} + +export interface GetOobCodeRequest { + email?: string; // Everything except VERIFY_AND_CHANGE_EMAIL + continueUrl?: string; + iosBundleId?: string; + iosAppStoreId?: string; + androidPackageName?: string; + androidInstallApp?: boolean; + androidMinimumVersionCode?: string; + canHandleCodeInApp?: boolean; + dynamicLinkDomain?: string; + tenantId?: string; + targetProjectid?: string; +} + +export interface VerifyEmailRequest extends GetOobCodeRequest { + requestType: Operation.VERIFY_EMAIL; + idToken: IdToken; +} + +export interface PasswordResetRequest extends GetOobCodeRequest { + requestType: Operation.PASSWORD_RESET; + email: string; + captchaResp?: string; + userIp?: string; +} + +export interface EmailSignInRequest extends GetOobCodeRequest { + requestType: Operation.EMAIL_SIGNIN; + email: string; +} + +interface GetOobCodeResponse { + email: string; +} + +export interface VerifyEmailResponse extends GetOobCodeResponse {} +export interface PasswordResetResponse extends GetOobCodeResponse {} +export interface EmailSignInResponse extends GetOobCodeResponse {} + +async function sendOobCode( + auth: Auth, + request: GetOobCodeRequest +): Promise { + return _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.SEND_OOB_CODE, + request + ); +} + +export async function sendEmailVerification( + auth: Auth, + request: VerifyEmailRequest +): Promise { + return sendOobCode(auth, request); +} + +export async function sendPasswordResetEmail( + auth: Auth, + request: PasswordResetRequest +): Promise { + return sendOobCode(auth, request); +} + +export async function sendSignInLinkToEmail( + auth: Auth, + request: EmailSignInRequest +): Promise { + return sendOobCode(auth, request); +} diff --git a/packages-exp/auth-exp/src/api/authentication/email_link.test.ts b/packages-exp/auth-exp/src/api/authentication/email_link.test.ts new file mode 100644 index 00000000000..abd8706f43d --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/email_link.test.ts @@ -0,0 +1,144 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { + signInWithEmailLink, + signInWithEmailLinkForLinking +} from './email_link'; + +use(chaiAsPromised); + +describe('api/authentication/email_link', () => { + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + context('signInWithEmailLink', () => { + const request = { + email: 'foo@bar.com', + oobCode: 'my-code' + }; + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SIGN_IN_WITH_EMAIL_LINK, { + displayName: 'my-name', + email: 'test@foo.com' + }); + + const response = await signInWithEmailLink(auth, request); + expect(response.displayName).to.eq('my-name'); + expect(response.email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_IN_WITH_EMAIL_LINK, + { + error: { + code: 400, + message: ServerError.INVALID_EMAIL, + errors: [ + { + message: ServerError.INVALID_EMAIL + } + ] + } + }, + 400 + ); + + await expect(signInWithEmailLink(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is badly formatted. (auth/invalid-email).' + ); + expect(mock.calls[0].request).to.eql(request); + }); + }); + + context('signInWithEmailLinkForLinking', () => { + const request = { + email: 'foo@bar.com', + oobCode: 'my-code', + idToken: 'id-token-2' + }; + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SIGN_IN_WITH_EMAIL_LINK, { + displayName: 'my-name', + email: 'test@foo.com' + }); + + const response = await signInWithEmailLinkForLinking(auth, request); + expect(response.displayName).to.eq('my-name'); + expect(response.email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_IN_WITH_EMAIL_LINK, + { + error: { + code: 400, + message: ServerError.INVALID_EMAIL, + errors: [ + { + message: ServerError.INVALID_EMAIL + } + ] + } + }, + 400 + ); + + await expect( + signInWithEmailLinkForLinking(auth, request) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is badly formatted. (auth/invalid-email).' + ); + expect(mock.calls[0].request).to.eql(request); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/email_link.ts b/packages-exp/auth-exp/src/api/authentication/email_link.ts new file mode 100644 index 00000000000..a14c81cb0bd --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/email_link.ts @@ -0,0 +1,55 @@ +/** + * @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 { _performSignInRequest, Endpoint, HttpMethod } from '../'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; + +export interface SignInWithEmailLinkRequest { + email: string; + oobCode: string; +} + +export interface SignInWithEmailLinkResponse extends IdTokenResponse { + email: string; + isNewUser: boolean; +} + +export async function signInWithEmailLink( + auth: Auth, + request: SignInWithEmailLinkRequest +): Promise { + return _performSignInRequest< + SignInWithEmailLinkRequest, + SignInWithEmailLinkResponse + >(auth, HttpMethod.POST, Endpoint.SIGN_IN_WITH_EMAIL_LINK, request); +} + +export interface SignInWithEmailLinkForLinkingRequest + extends SignInWithEmailLinkRequest { + idToken: string; +} + +export async function signInWithEmailLinkForLinking( + auth: Auth, + request: SignInWithEmailLinkForLinkingRequest +): Promise { + return _performSignInRequest< + SignInWithEmailLinkForLinkingRequest, + SignInWithEmailLinkResponse + >(auth, HttpMethod.POST, Endpoint.SIGN_IN_WITH_EMAIL_LINK, request); +} diff --git a/packages-exp/auth-exp/src/api/authentication/idp.test.ts b/packages-exp/auth-exp/src/api/authentication/idp.test.ts new file mode 100644 index 00000000000..bbc6a88c236 --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/idp.test.ts @@ -0,0 +1,89 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { signInWithIdp } from './idp'; + +use(chaiAsPromised); + +describe('api/authentication/signInWithIdp', () => { + const request = { + returnSecureToken: true, + requestUri: 'request-uri', + postBody: null + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SIGN_IN_WITH_IDP, { + displayName: 'my-name', + idToken: 'id-token' + }); + + const response = await signInWithIdp(auth, request); + expect(response.displayName).to.eq('my-name'); + expect(response.idToken).to.eq('id-token'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_IN_WITH_IDP, + { + error: { + code: 400, + message: ServerError.INVALID_IDP_RESPONSE, + errors: [ + { + message: ServerError.INVALID_IDP_RESPONSE + } + ] + } + }, + 400 + ); + + await expect(signInWithIdp(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The supplied auth credential is malformed or has expired. (auth/invalid-credential).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/idp.ts b/packages-exp/auth-exp/src/api/authentication/idp.ts new file mode 100644 index 00000000000..5e21d9c3e9b --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/idp.ts @@ -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 { Endpoint, HttpMethod, _performSignInRequest } from '..'; +import { Auth } from '../../model/auth'; +import { IdToken, IdTokenResponse } from '../../model/id_token'; + +export interface SignInWithIdpRequest { + requestUri: string; + postBody: string | null; + sessionId?: string; + tenantId?: string; + returnSecureToken: boolean; + idToken?: IdToken; + autoCreate?: boolean; + pendingToken?: string; +} + +export interface SignInWithIdpResponse extends IdTokenResponse { + oauthAccessToken?: string; + oauthTokenSecret?: string; + nonce?: string; + oauthIdToken?: string; + pendingToken?: string; +} + +export async function signInWithIdp( + auth: Auth, + request: SignInWithIdpRequest +): Promise { + return _performSignInRequest( + auth, + HttpMethod.POST, + Endpoint.SIGN_IN_WITH_IDP, + request + ); +} diff --git a/packages-exp/auth-exp/src/api/authentication/mfa.test.ts b/packages-exp/auth-exp/src/api/authentication/mfa.test.ts new file mode 100644 index 00000000000..6b053bd064f --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/mfa.test.ts @@ -0,0 +1,153 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { finalizeSignInPhoneMfa, startSignInPhoneMfa } from './mfa'; + +use(chaiAsPromised); + +describe('api/authentication/startSignInPhoneMfa', () => { + const request = { + mfaPendingCredential: 'my-creds', + mfaEnrollmentId: 'my-enrollment-id', + phoneSignInInfo: { + recaptchaToken: 'catpcha-token' + } + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.START_PHONE_MFA_SIGN_IN, { + phoneResponseInfo: { + sessionInfo: 'session-info' + } + }); + + const response = await startSignInPhoneMfa(auth, request); + expect(response.phoneResponseInfo.sessionInfo).to.eq('session-info'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.START_PHONE_MFA_SIGN_IN, + { + error: { + code: 400, + message: ServerError.INVALID_PENDING_TOKEN, + errors: [ + { + message: ServerError.INVALID_PENDING_TOKEN + } + ] + } + }, + 400 + ); + + await expect(startSignInPhoneMfa(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The supplied auth credential is malformed or has expired. (auth/invalid-credential).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/authentication/finalizeSignInPhoneMfa', () => { + const request = { + mfaPendingCredential: 'pending-cred', + phoneVerificationInfo: { + temporaryProof: 'proof', + phoneNumber: '123456789', + sessionInfo: 'session-info', + code: 'my-code' + } + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.FINALIZE_PHONE_MFA_SIGN_IN, { + displayName: 'my-name', + idToken: 'id-token' + }); + + const response = await finalizeSignInPhoneMfa(auth, request); + expect(response.displayName).to.eq('my-name'); + expect(response.idToken).to.eq('id-token'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.FINALIZE_PHONE_MFA_SIGN_IN, + { + error: { + code: 400, + message: ServerError.INVALID_CODE, + errors: [ + { + message: ServerError.INVALID_CODE + } + ] + } + }, + 400 + ); + + await expect(finalizeSignInPhoneMfa(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The SMS verification code used to create the phone auth credential is invalid. Please resend the verification code sms and be sure use the verification code provided by the user. (auth/invalid-verification-code).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/mfa.ts b/packages-exp/auth-exp/src/api/authentication/mfa.ts new file mode 100644 index 00000000000..33b28cd96d6 --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/mfa.ts @@ -0,0 +1,71 @@ +/** + * @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 { Endpoint, HttpMethod, _performApiRequest } from '..'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { SignInWithIdpResponse } from './idp'; +import { + SignInWithPhoneNumberRequest, + SignInWithPhoneNumberResponse +} from './sms'; + +export interface StartPhoneMfaSignInRequest { + mfaPendingCredential: string; + mfaEnrollmentId: string; + phoneSignInInfo: { + recaptchaToken: string; + }; +} + +export interface StartPhoneMfaSignInResponse { + phoneResponseInfo: { + sessionInfo: string; + }; +} + +export function startSignInPhoneMfa( + auth: Auth, + request: StartPhoneMfaSignInRequest +): Promise { + return _performApiRequest< + StartPhoneMfaSignInRequest, + StartPhoneMfaSignInResponse + >(auth, HttpMethod.POST, Endpoint.START_PHONE_MFA_SIGN_IN, request); +} + +export interface FinalizePhoneMfaSignInRequest { + mfaPendingCredential: string; + phoneVerificationInfo: SignInWithPhoneNumberRequest; +} + +export interface FinalizePhoneMfaSignInResponse extends IdTokenResponse {} + +export function finalizeSignInPhoneMfa( + auth: Auth, + request: FinalizePhoneMfaSignInRequest +): Promise { + return _performApiRequest< + FinalizePhoneMfaSignInRequest, + FinalizePhoneMfaSignInResponse + >(auth, HttpMethod.POST, Endpoint.FINALIZE_PHONE_MFA_SIGN_IN, request); +} + +export type PhoneOrOauthTokenResponse = + | SignInWithPhoneNumberResponse + | SignInWithIdpResponse + | IdTokenResponse; diff --git a/packages-exp/auth-exp/src/api/authentication/recaptcha.test.ts b/packages-exp/auth-exp/src/api/authentication/recaptcha.test.ts new file mode 100644 index 00000000000..489f11024dc --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/recaptcha.test.ts @@ -0,0 +1,81 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { getRecaptchaParams } from './recaptcha'; + +use(chaiAsPromised); + +describe('api/authentication/getRecaptchaParams', () => { + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should GET to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.GET_RECAPTCHA_PARAM, { + recaptchaSiteKey: 'site-key' + }); + + const response = await getRecaptchaParams(auth); + expect(response).to.eq('site-key'); + expect(mock.calls[0].request).to.be.undefined; + expect(mock.calls[0].method).to.eq('GET'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.GET_RECAPTCHA_PARAM, + { + error: { + code: 400, + message: ServerError.TOO_MANY_ATTEMPTS_TRY_LATER, + errors: [ + { + message: ServerError.TOO_MANY_ATTEMPTS_TRY_LATER + } + ] + } + }, + 400 + ); + + await expect(getRecaptchaParams(auth)).to.be.rejectedWith( + FirebaseError, + 'Firebase: We have blocked all requests from this device due to unusual activity. Try again later. (auth/too-many-requests).' + ); + expect(mock.calls[0].request).to.be.undefined; + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/recaptcha.ts b/packages-exp/auth-exp/src/api/authentication/recaptcha.ts new file mode 100644 index 00000000000..edaa13ea129 --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/recaptcha.ts @@ -0,0 +1,35 @@ +/** + * @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 { Endpoint, HttpMethod, _performApiRequest } from '..'; +import { Auth } from '../../model/auth'; + +interface GetRecaptchaParamResponse { + recaptchaSiteKey?: string; +} + +export async function getRecaptchaParams(auth: Auth): Promise { + return ( + ( + await _performApiRequest( + auth, + HttpMethod.GET, + Endpoint.GET_RECAPTCHA_PARAM + ) + ).recaptchaSiteKey || '' + ); +} diff --git a/packages-exp/auth-exp/src/api/authentication/sign_up.test.ts b/packages-exp/auth-exp/src/api/authentication/sign_up.test.ts new file mode 100644 index 00000000000..9aaa91092ce --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/sign_up.test.ts @@ -0,0 +1,89 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { signUp } from './sign_up'; + +use(chaiAsPromised); + +describe('api/authentication/signUp', () => { + const request = { + returnSecureToken: true, + email: 'test@foo.com', + password: 'my-password' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SIGN_UP, { + displayName: 'my-name', + email: 'test@foo.com' + }); + + const response = await signUp(auth, request); + expect(response.displayName).to.eq('my-name'); + expect(response.email).to.eq('test@foo.com'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_UP, + { + error: { + code: 400, + message: ServerError.EMAIL_EXISTS, + errors: [ + { + message: ServerError.EMAIL_EXISTS + } + ] + } + }, + 400 + ); + + await expect(signUp(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is already in use by another account. (auth/email-already-in-use).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/sign_up.ts b/packages-exp/auth-exp/src/api/authentication/sign_up.ts new file mode 100644 index 00000000000..adf0c8afeeb --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/sign_up.ts @@ -0,0 +1,43 @@ +/** + * @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 { Endpoint, HttpMethod, _performSignInRequest } from '..'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; + +export interface SignUpRequest { + returnSecureToken?: boolean; + email?: string; + password?: string; +} + +export interface SignUpResponse extends IdTokenResponse { + displayName?: string; + email?: string; +} + +export async function signUp( + auth: Auth, + request: SignUpRequest +): Promise { + return _performSignInRequest( + auth, + HttpMethod.POST, + Endpoint.SIGN_UP, + request + ); +} diff --git a/packages-exp/auth-exp/src/api/authentication/sms.test.ts b/packages-exp/auth-exp/src/api/authentication/sms.test.ts new file mode 100644 index 00000000000..23c40732483 --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/sms.test.ts @@ -0,0 +1,293 @@ +/** + * @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 { ProviderId } from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { Endpoint } from '../'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { + linkWithPhoneNumber, + sendPhoneVerificationCode, + signInWithPhoneNumber, + verifyPhoneNumberForExisting +} from './sms'; + +use(chaiAsPromised); + +describe('api/authentication/sendPhoneVerificationCode', () => { + const request = { + phoneNumber: '123456789', + recaptchaToken: 'captchad' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SEND_VERIFICATION_CODE, { + sessionInfo: 'my-session' + }); + + const response = await sendPhoneVerificationCode(auth, request); + expect(response.sessionInfo).to.eq('my-session'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SEND_VERIFICATION_CODE, + { + error: { + code: 400, + message: ServerError.INVALID_PHONE_NUMBER, + errors: [ + { + message: ServerError.INVALID_PHONE_NUMBER + } + ] + } + }, + 400 + ); + + await expect(sendPhoneVerificationCode(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The format of the phone number provided is incorrect. Please enter the phone number in a format that can be parsed into E.164 format. E.164 phone numbers are written in the format [+][country code][subscriber number including area code]. (auth/invalid-phone-number).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/authentication/signInWithPhoneNumber', () => { + const request = { + temporaryProof: 'my-proof', + phoneNumber: '1234567', + sessionInfo: 'my-session', + code: 'my-code' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, { + providerId: ProviderId.PHONE, + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1000', + localId: '1234' + }); + + const response = await signInWithPhoneNumber(auth, request); + expect(response.providerId).to.eq(ProviderId.PHONE); + expect(response.idToken).to.eq('id-token'); + expect(response.expiresIn).to.eq('1000'); + expect(response.localId).to.eq('1234'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_IN_WITH_PHONE_NUMBER, + { + error: { + code: 400, + message: ServerError.INVALID_CODE, + errors: [ + { + message: ServerError.INVALID_CODE + } + ] + } + }, + 400 + ); + + await expect(signInWithPhoneNumber(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The SMS verification code used to create the phone auth credential is invalid. Please resend the verification code sms and be sure use the verification code provided by the user. (auth/invalid-verification-code).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/authentication/linkWithPhoneNumber', () => { + const request = { + idToken: 'id-token', + temporaryProof: 'my-proof', + phoneNumber: '1234567', + sessionInfo: 'my-session', + code: 'my-code' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, { + providerId: ProviderId.PHONE, + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1000', + localId: '1234' + }); + + const response = await linkWithPhoneNumber(auth, request); + expect(response.providerId).to.eq(ProviderId.PHONE); + expect(response.idToken).to.eq('id-token'); + expect(response.expiresIn).to.eq('1000'); + expect(response.localId).to.eq('1234'); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_IN_WITH_PHONE_NUMBER, + { + error: { + code: 400, + message: ServerError.INVALID_CODE, + errors: [ + { + message: ServerError.INVALID_CODE + } + ] + } + }, + 400 + ); + + await expect(linkWithPhoneNumber(auth, request)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The SMS verification code used to create the phone auth credential is invalid. Please resend the verification code sms and be sure use the verification code provided by the user. (auth/invalid-verification-code).' + ); + expect(mock.calls[0].request).to.eql(request); + }); +}); + +describe('api/authentication/verifyPhoneNumberForExisting', () => { + const request = { + temporaryProof: 'my-proof', + phoneNumber: '1234567', + sessionInfo: 'my-session', + code: 'my-code' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, { + providerId: ProviderId.PHONE, + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1000', + localId: '1234' + }); + + const response = await verifyPhoneNumberForExisting(auth, request); + expect(response.providerId).to.eq(ProviderId.PHONE); + expect(response.idToken).to.eq('id-token'); + expect(response.expiresIn).to.eq('1000'); + expect(response.localId).to.eq('1234'); + expect(mock.calls[0].request).to.eql({ + ...request, + operation: 'REAUTH' + }); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle custom errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_IN_WITH_PHONE_NUMBER, + { + error: { + code: 400, + message: ServerError.USER_NOT_FOUND, + errors: [ + { + message: ServerError.USER_NOT_FOUND + } + ] + } + }, + 400 + ); + + await expect( + verifyPhoneNumberForExisting(auth, request) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: There is no user record corresponding to this identifier. The user may have been deleted. (auth/user-not-found).' + ); + expect(mock.calls[0].request).to.eql({ + ...request, + operation: 'REAUTH' + }); + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/sms.ts b/packages-exp/auth-exp/src/api/authentication/sms.ts new file mode 100644 index 00000000000..634eac7a103 --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/sms.ts @@ -0,0 +1,114 @@ +/** + * @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 { + Endpoint, + HttpMethod, + _performApiRequest, + _performSignInRequest +} from '..'; +import { AuthErrorCode } from '../../core/errors'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { ServerError, ServerErrorMap } from '../errors'; + +export interface SendPhoneVerificationCodeRequest { + phoneNumber: string; + recaptchaToken: string; +} + +export interface SendPhoneVerificationCodeResponse { + sessionInfo: string; +} + +export async function sendPhoneVerificationCode( + auth: Auth, + request: SendPhoneVerificationCodeRequest +): Promise { + return _performApiRequest< + SendPhoneVerificationCodeRequest, + SendPhoneVerificationCodeResponse + >(auth, HttpMethod.POST, Endpoint.SEND_VERIFICATION_CODE, request); +} + +export interface SignInWithPhoneNumberRequest { + temporaryProof?: string; + phoneNumber?: string; + sessionInfo?: string; + code?: string; +} + +export interface LinkWithPhoneNumberRequest + extends SignInWithPhoneNumberRequest { + idToken: string; +} + +export interface SignInWithPhoneNumberResponse extends IdTokenResponse { + temporaryProof?: string; + phoneNumber?: string; +} + +export async function signInWithPhoneNumber( + auth: Auth, + request: SignInWithPhoneNumberRequest +): Promise { + return _performSignInRequest< + SignInWithPhoneNumberRequest, + SignInWithPhoneNumberResponse + >(auth, HttpMethod.POST, Endpoint.SIGN_IN_WITH_PHONE_NUMBER, request); +} + +export async function linkWithPhoneNumber( + auth: Auth, + request: LinkWithPhoneNumberRequest +): Promise { + return _performSignInRequest< + LinkWithPhoneNumberRequest, + SignInWithPhoneNumberResponse + >(auth, HttpMethod.POST, Endpoint.SIGN_IN_WITH_PHONE_NUMBER, request); +} + +interface VerifyPhoneNumberForExistingRequest + extends SignInWithPhoneNumberRequest { + operation: 'REAUTH'; +} + +const VERIFY_PHONE_NUMBER_FOR_EXISTING_ERROR_MAP_: Partial> = { + [ServerError.USER_NOT_FOUND]: AuthErrorCode.USER_DELETED +}; + +export async function verifyPhoneNumberForExisting( + auth: Auth, + request: SignInWithPhoneNumberRequest +): Promise { + const apiRequest: VerifyPhoneNumberForExistingRequest = { + ...request, + operation: 'REAUTH' + }; + return _performSignInRequest< + VerifyPhoneNumberForExistingRequest, + SignInWithPhoneNumberResponse + >( + auth, + HttpMethod.POST, + Endpoint.SIGN_IN_WITH_PHONE_NUMBER, + apiRequest, + VERIFY_PHONE_NUMBER_FOR_EXISTING_ERROR_MAP_ + ); +} diff --git a/packages-exp/auth-exp/src/api/authentication/token.test.ts b/packages-exp/auth-exp/src/api/authentication/token.test.ts new file mode 100644 index 00000000000..ae99b04d1ce --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/token.test.ts @@ -0,0 +1,94 @@ +/** + * @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 { FirebaseError, querystringDecode } from '@firebase/util'; + +import { testAuth } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Auth } from '../../model/auth'; +import { ServerError } from '../errors'; +import { _ENDPOINT, requestStsToken } from './token'; + +use(chaiAsPromised); + +describe('requestStsToken', () => { + let auth: Auth; + let endpoint: string; + + beforeEach(async () => { + auth = await testAuth(); + const { apiKey, tokenApiHost, apiScheme } = auth.config; + endpoint = `${apiScheme}://${tokenApiHost}/${_ENDPOINT}?key=${apiKey}`; + fetch.setUp(); + }); + + afterEach(fetch.tearDown); + + it('should POST to the correct endpoint', async () => { + const mock = fetch.mock(endpoint, { + 'access_token': 'new-access-token', + 'expires_in': '3600', + 'refresh_token': 'new-refresh-token' + }); + + const response = await requestStsToken(auth, 'old-refresh-token'); + expect(response.accessToken).to.eq('new-access-token'); + expect(response.expiresIn).to.eq('3600'); + expect(response.refreshToken).to.eq('new-refresh-token'); + const request = querystringDecode(`?${mock.calls[0].request}`); + expect(request).to.eql({ + 'grant_type': 'refresh_token', + 'refresh_token': 'old-refresh-token' + }); + expect(mock.calls[0].method).to.eq('POST'); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should handle errors', async () => { + const mock = fetch.mock( + endpoint, + { + error: { + code: 400, + message: ServerError.TOKEN_EXPIRED, + errors: [ + { + message: ServerError.TOKEN_EXPIRED + } + ] + } + }, + 400 + ); + + await expect(requestStsToken(auth, 'old-token')).to.be.rejectedWith( + FirebaseError, + "Firebase: The user's credential is no longer valid. The user must sign in again. (auth/user-token-expired)" + ); + const request = querystringDecode(`?${mock.calls[0].request}`); + expect(request).to.eql({ + 'grant_type': 'refresh_token', + 'refresh_token': 'old-token' + }); + }); +}); diff --git a/packages-exp/auth-exp/src/api/authentication/token.ts b/packages-exp/auth-exp/src/api/authentication/token.ts new file mode 100644 index 00000000000..ceb186b6d7d --- /dev/null +++ b/packages-exp/auth-exp/src/api/authentication/token.ts @@ -0,0 +1,71 @@ +/** + * @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. + */ + +/* eslint-disable camelcase */ + +import { querystring } from '@firebase/util'; + +import { _performFetchWithErrorHandling, HttpMethod } from '../'; +import { Auth } from '../../model/auth'; + +export const _ENDPOINT = 'v1/token'; +const GRANT_TYPE = 'refresh_token'; + +/** The server responses with snake_case; we convert to camelCase */ +interface RequestStsTokenServerResponse { + access_token?: string; + expires_in?: string; + refresh_token?: string; +} + +export interface RequestStsTokenResponse { + accessToken?: string; + expiresIn?: string; + refreshToken?: string; +} + +export async function requestStsToken( + auth: Auth, + refreshToken: string +): Promise { + const response = await _performFetchWithErrorHandling< + RequestStsTokenServerResponse + >(auth, {}, () => { + const body = querystring({ + 'grant_type': GRANT_TYPE, + 'refresh_token': refreshToken + }).slice(1); + const { apiScheme, tokenApiHost, apiKey, sdkClientVersion } = auth.config; + const url = `${apiScheme}://${tokenApiHost}/${_ENDPOINT}`; + + return fetch(`${url}?key=${apiKey}`, { + method: HttpMethod.POST, + headers: { + 'X-Client-Version': sdkClientVersion, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body + }); + }); + + // The response comes back in snake_case. Convert to camel: + return { + accessToken: response.access_token, + expiresIn: response.expires_in, + refreshToken: response.refresh_token + }; +} diff --git a/packages-exp/auth-exp/src/api/errors.ts b/packages-exp/auth-exp/src/api/errors.ts new file mode 100644 index 00000000000..d060c3151f9 --- /dev/null +++ b/packages-exp/auth-exp/src/api/errors.ts @@ -0,0 +1,228 @@ +/** + * @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 { AuthErrorCode } from '../core/errors'; + +/** + * Errors that can be returned by the backend + */ +export enum ServerError { + ADMIN_ONLY_OPERATION = 'ADMIN_ONLY_OPERATION', + CAPTCHA_CHECK_FAILED = 'CAPTCHA_CHECK_FAILED', + CORS_UNSUPPORTED = 'CORS_UNSUPPORTED', + CREDENTIAL_MISMATCH = 'CREDENTIAL_MISMATCH', + CREDENTIAL_TOO_OLD_LOGIN_AGAIN = 'CREDENTIAL_TOO_OLD_LOGIN_AGAIN', + DYNAMIC_LINK_NOT_ACTIVATED = 'DYNAMIC_LINK_NOT_ACTIVATED', + EMAIL_EXISTS = 'EMAIL_EXISTS', + EMAIL_NOT_FOUND = 'EMAIL_NOT_FOUND', + EXPIRED_OOB_CODE = 'EXPIRED_OOB_CODE', + FEDERATED_USER_ID_ALREADY_LINKED = 'FEDERATED_USER_ID_ALREADY_LINKED', + INVALID_APP_CREDENTIAL = 'INVALID_APP_CREDENTIAL', + INVALID_APP_ID = 'INVALID_APP_ID', + INVALID_CERT_HASH = 'INVALID_CERT_HASH', + INVALID_CODE = 'INVALID_CODE', + INVALID_CONTINUE_URI = 'INVALID_CONTINUE_URI', + INVALID_CUSTOM_TOKEN = 'INVALID_CUSTOM_TOKEN', + INVALID_DYNAMIC_LINK_DOMAIN = 'INVALID_DYNAMIC_LINK_DOMAIN', + INVALID_EMAIL = 'INVALID_EMAIL', + INVALID_ID_TOKEN = 'INVALID_ID_TOKEN', + INVALID_IDP_RESPONSE = 'INVALID_IDP_RESPONSE', + INVALID_IDENTIFIER = 'INVALID_IDENTIFIER', + INVALID_MESSAGE_PAYLOAD = 'INVALID_MESSAGE_PAYLOAD', + INVALID_OAUTH_CLIENT_ID = 'INVALID_OAUTH_CLIENT_ID', + INVALID_OOB_CODE = 'INVALID_OOB_CODE', + INVALID_PASSWORD = 'INVALID_PASSWORD', + INVALID_PENDING_TOKEN = 'INVALID_PENDING_TOKEN', + INVALID_PHONE_NUMBER = 'INVALID_PHONE_NUMBER', + INVALID_PROVIDER_ID = 'INVALID_PROVIDER_ID', + INVALID_RECIPIENT_EMAIL = 'INVALID_RECIPIENT_EMAIL', + INVALID_SENDER = 'INVALID_SENDER', + INVALID_SESSION_INFO = 'INVALID_SESSION_INFO', + INVALID_TEMPORARY_PROOF = 'INVALID_TEMPORARY_PROOF', + INVALID_TENANT_ID = 'INVALID_TENANT_ID', + MISSING_ANDROID_PACKAGE_NAME = 'MISSING_ANDROID_PACKAGE_NAME', + MISSING_APP_CREDENTIAL = 'MISSING_APP_CREDENTIAL', + MISSING_CODE = 'MISSING_CODE', + MISSING_CONTINUE_URI = 'MISSING_CONTINUE_URI', + MISSING_CUSTOM_TOKEN = 'MISSING_CUSTOM_TOKEN', + MISSING_IOS_BUNDLE_ID = 'MISSING_IOS_BUNDLE_ID', + MISSING_OOB_CODE = 'MISSING_OOB_CODE', + MISSING_OR_INVALID_NONCE = 'MISSING_OR_INVALID_NONCE', + MISSING_PASSWORD = 'MISSING_PASSWORD', + MISSING_REQ_TYPE = 'MISSING_REQ_TYPE', + MISSING_PHONE_NUMBER = 'MISSING_PHONE_NUMBER', + MISSING_SESSION_INFO = 'MISSING_SESSION_INFO', + OPERATION_NOT_ALLOWED = 'OPERATION_NOT_ALLOWED', + PASSWORD_LOGIN_DISABLED = 'PASSWORD_LOGIN_DISABLED', + QUOTA_EXCEEDED = 'QUOTA_EXCEEDED', + RESET_PASSWORD_EXCEED_LIMIT = 'RESET_PASSWORD_EXCEED_LIMIT', + REJECTED_CREDENTIAL = 'REJECTED_CREDENTIAL', + SESSION_EXPIRED = 'SESSION_EXPIRED', + TENANT_ID_MISMATCH = 'TENANT_ID_MISMATCH', + TOKEN_EXPIRED = 'TOKEN_EXPIRED', + TOO_MANY_ATTEMPTS_TRY_LATER = 'TOO_MANY_ATTEMPTS_TRY_LATER', + UNSUPPORTED_TENANT_OPERATION = 'UNSUPPORTED_TENANT_OPERATION', + UNAUTHORIZED_DOMAIN = 'UNAUTHORIZED_DOMAIN', + USER_CANCELLED = 'USER_CANCELLED', + USER_DISABLED = 'USER_DISABLED', + USER_NOT_FOUND = 'USER_NOT_FOUND', + WEAK_PASSWORD = 'WEAK_PASSWORD' +} + +/** + * API Response in the event of an error + */ +export interface JsonError { + error: { + code: number; + message: ServerError; + errors: [ + { + message: ServerError; + domain: string; + reason: string; + } + ]; + }; +} + +/** + * Type definition for a map from server errors to developer visible errors + */ +export declare type ServerErrorMap = { + readonly [K in ApiError]: AuthErrorCode; +}; + +/** + * Map from errors returned by the server to errors to developer visible errors + */ +export const SERVER_ERROR_MAP: ServerErrorMap = { + // Custom token errors. + [ServerError.INVALID_CUSTOM_TOKEN]: AuthErrorCode.INVALID_CUSTOM_TOKEN, + [ServerError.CREDENTIAL_MISMATCH]: AuthErrorCode.CREDENTIAL_MISMATCH, + // This can only happen if the SDK sends a bad request. + [ServerError.MISSING_CUSTOM_TOKEN]: AuthErrorCode.INTERNAL_ERROR, + + // Create Auth URI errors. + [ServerError.INVALID_IDENTIFIER]: AuthErrorCode.INVALID_EMAIL, + // This can only happen if the SDK sends a bad request. + [ServerError.MISSING_CONTINUE_URI]: AuthErrorCode.INTERNAL_ERROR, + + // Sign in with email and password errors (some apply to sign up too). + [ServerError.INVALID_EMAIL]: AuthErrorCode.INVALID_EMAIL, + [ServerError.INVALID_PASSWORD]: AuthErrorCode.INVALID_PASSWORD, + [ServerError.USER_DISABLED]: AuthErrorCode.USER_DISABLED, + // This can only happen if the SDK sends a bad request. + [ServerError.MISSING_PASSWORD]: AuthErrorCode.INTERNAL_ERROR, + + // Sign up with email and password errors. + [ServerError.EMAIL_EXISTS]: AuthErrorCode.EMAIL_EXISTS, + [ServerError.PASSWORD_LOGIN_DISABLED]: AuthErrorCode.OPERATION_NOT_ALLOWED, + + // Verify assertion for sign in with credential errors: + [ServerError.INVALID_IDP_RESPONSE]: AuthErrorCode.INVALID_IDP_RESPONSE, + [ServerError.INVALID_PENDING_TOKEN]: AuthErrorCode.INVALID_IDP_RESPONSE, + [ServerError.FEDERATED_USER_ID_ALREADY_LINKED]: + AuthErrorCode.CREDENTIAL_ALREADY_IN_USE, + [ServerError.MISSING_OR_INVALID_NONCE]: + AuthErrorCode.MISSING_OR_INVALID_NONCE, + + // Email template errors while sending emails: + [ServerError.INVALID_MESSAGE_PAYLOAD]: AuthErrorCode.INVALID_MESSAGE_PAYLOAD, + [ServerError.INVALID_RECIPIENT_EMAIL]: AuthErrorCode.INVALID_RECIPIENT_EMAIL, + [ServerError.INVALID_SENDER]: AuthErrorCode.INVALID_SENDER, + // This can only happen if the SDK sends a bad request. + [ServerError.MISSING_REQ_TYPE]: AuthErrorCode.INTERNAL_ERROR, + + // Send Password reset email errors: + [ServerError.EMAIL_NOT_FOUND]: AuthErrorCode.USER_DELETED, + [ServerError.RESET_PASSWORD_EXCEED_LIMIT]: + AuthErrorCode.TOO_MANY_ATTEMPTS_TRY_LATER, + + // Reset password errors: + [ServerError.EXPIRED_OOB_CODE]: AuthErrorCode.EXPIRED_OOB_CODE, + [ServerError.INVALID_OOB_CODE]: AuthErrorCode.INVALID_OOB_CODE, + // This can only happen if the SDK sends a bad request. + [ServerError.MISSING_OOB_CODE]: AuthErrorCode.INTERNAL_ERROR, + + // Get Auth URI errors: + [ServerError.INVALID_PROVIDER_ID]: AuthErrorCode.INVALID_PROVIDER_ID, + + // Operations that require ID token in request: + [ServerError.CREDENTIAL_TOO_OLD_LOGIN_AGAIN]: + AuthErrorCode.CREDENTIAL_TOO_OLD_LOGIN_AGAIN, + [ServerError.INVALID_ID_TOKEN]: AuthErrorCode.INVALID_AUTH, + [ServerError.TOKEN_EXPIRED]: AuthErrorCode.TOKEN_EXPIRED, + [ServerError.USER_NOT_FOUND]: AuthErrorCode.TOKEN_EXPIRED, + + // CORS issues. + [ServerError.CORS_UNSUPPORTED]: AuthErrorCode.CORS_UNSUPPORTED, + + // Dynamic link not activated. + [ServerError.DYNAMIC_LINK_NOT_ACTIVATED]: + AuthErrorCode.DYNAMIC_LINK_NOT_ACTIVATED, + + // iosBundleId or androidPackageName not valid error. + [ServerError.INVALID_APP_ID]: AuthErrorCode.INVALID_APP_ID, + + // Other errors. + [ServerError.TOO_MANY_ATTEMPTS_TRY_LATER]: + AuthErrorCode.TOO_MANY_ATTEMPTS_TRY_LATER, + [ServerError.WEAK_PASSWORD]: AuthErrorCode.WEAK_PASSWORD, + [ServerError.OPERATION_NOT_ALLOWED]: AuthErrorCode.OPERATION_NOT_ALLOWED, + [ServerError.USER_CANCELLED]: AuthErrorCode.USER_CANCELLED, + + // Phone Auth related errors. + [ServerError.CAPTCHA_CHECK_FAILED]: AuthErrorCode.CAPTCHA_CHECK_FAILED, + [ServerError.INVALID_APP_CREDENTIAL]: AuthErrorCode.INVALID_APP_CREDENTIAL, + [ServerError.INVALID_CODE]: AuthErrorCode.INVALID_CODE, + [ServerError.INVALID_PHONE_NUMBER]: AuthErrorCode.INVALID_PHONE_NUMBER, + [ServerError.INVALID_SESSION_INFO]: AuthErrorCode.INVALID_SESSION_INFO, + [ServerError.INVALID_TEMPORARY_PROOF]: AuthErrorCode.INVALID_IDP_RESPONSE, + [ServerError.MISSING_APP_CREDENTIAL]: AuthErrorCode.MISSING_APP_CREDENTIAL, + [ServerError.MISSING_CODE]: AuthErrorCode.MISSING_CODE, + [ServerError.MISSING_PHONE_NUMBER]: AuthErrorCode.MISSING_PHONE_NUMBER, + [ServerError.MISSING_SESSION_INFO]: AuthErrorCode.MISSING_SESSION_INFO, + [ServerError.QUOTA_EXCEEDED]: AuthErrorCode.QUOTA_EXCEEDED, + [ServerError.SESSION_EXPIRED]: AuthErrorCode.CODE_EXPIRED, + [ServerError.REJECTED_CREDENTIAL]: AuthErrorCode.REJECTED_CREDENTIAL, + + // Other action code errors when additional settings passed. + [ServerError.INVALID_CONTINUE_URI]: AuthErrorCode.INVALID_CONTINUE_URI, + // MISSING_CONTINUE_URI is getting mapped to INTERNAL_ERROR above. + // This is OK as this error will be caught by client side validation. + [ServerError.MISSING_ANDROID_PACKAGE_NAME]: + AuthErrorCode.MISSING_ANDROID_PACKAGE_NAME, + [ServerError.MISSING_IOS_BUNDLE_ID]: AuthErrorCode.MISSING_IOS_BUNDLE_ID, + [ServerError.UNAUTHORIZED_DOMAIN]: AuthErrorCode.UNAUTHORIZED_DOMAIN, + [ServerError.INVALID_DYNAMIC_LINK_DOMAIN]: + AuthErrorCode.INVALID_DYNAMIC_LINK_DOMAIN, + + // getProjectConfig errors when clientId is passed. + [ServerError.INVALID_OAUTH_CLIENT_ID]: AuthErrorCode.INVALID_OAUTH_CLIENT_ID, + // getProjectConfig errors when sha1Cert is passed. + [ServerError.INVALID_CERT_HASH]: AuthErrorCode.INVALID_CERT_HASH, + + // Multi-tenant related errors. + [ServerError.UNSUPPORTED_TENANT_OPERATION]: + AuthErrorCode.UNSUPPORTED_TENANT_OPERATION, + [ServerError.INVALID_TENANT_ID]: AuthErrorCode.INVALID_TENANT_ID, + [ServerError.TENANT_ID_MISMATCH]: AuthErrorCode.TENANT_ID_MISMATCH, + + // User actions (sign-up or deletion) disabled errors. + [ServerError.ADMIN_ONLY_OPERATION]: AuthErrorCode.ADMIN_ONLY_OPERATION +}; diff --git a/packages-exp/auth-exp/src/api/index.test.ts b/packages-exp/auth-exp/src/api/index.test.ts new file mode 100644 index 00000000000..49ccd8469e8 --- /dev/null +++ b/packages-exp/auth-exp/src/api/index.test.ts @@ -0,0 +1,213 @@ +/** + * @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 { SinonStub, stub, useFakeTimers } from 'sinon'; + +import { FirebaseError } from '@firebase/util'; + +import { mockEndpoint } from '../../test/api/helper'; +import { testAuth } from '../../test/mock_auth'; +import * as mockFetch from '../../test/mock_fetch'; +import { AuthErrorCode } from '../core/errors'; +import { Auth } from '../model/auth'; +import { + _performApiRequest, + DEFAULT_API_TIMEOUT_MS, + Endpoint, + HttpMethod +} from './'; +import { ServerError } from './errors'; + +use(chaiAsPromised); + +describe('api/_performApiRequest', () => { + const request = { + requestKey: 'request-value' + }; + + const serverResponse = { + responseKey: 'response-value' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + }); + + context('with regular requests', () => { + beforeEach(mockFetch.setUp); + afterEach(mockFetch.tearDown); + + it('should set the correct request, method and HTTP Headers', async () => { + const mock = mockEndpoint(Endpoint.SIGN_UP, serverResponse); + const response = await _performApiRequest< + typeof request, + typeof serverResponse + >(auth, HttpMethod.POST, Endpoint.SIGN_UP, request); + expect(response).to.eql(serverResponse); + expect(mock.calls.length).to.eq(1); + expect(mock.calls[0].method).to.eq(HttpMethod.POST); + expect(mock.calls[0].request).to.eql(request); + expect(mock.calls[0].headers).to.eql({ + 'Content-Type': 'application/json', + 'X-Client-Version': 'testSDK/0.0.0' + }); + }); + + it('should translate server errors to auth errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_UP, + { + error: { + code: 400, + message: ServerError.EMAIL_EXISTS, + errors: [ + { + message: ServerError.EMAIL_EXISTS + } + ] + } + }, + 400 + ); + const promise = _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.SIGN_UP, + request + ); + await expect(promise).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is already in use by another account. (auth/email-already-in-use).' + ); + expect(mock.calls[0].request).to.eql(request); + }); + + it('should handle unknown server errors', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_UP, + { + error: { + code: 400, + message: 'Awesome error', + errors: [ + { + message: 'Awesome error' + } + ] + } + }, + 400 + ); + const promise = _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.SIGN_UP, + request + ); + await expect(promise).to.be.rejectedWith( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + expect(mock.calls[0].request).to.eql(request); + }); + + it('should support custom error handling per endpoint', async () => { + const mock = mockEndpoint( + Endpoint.SIGN_UP, + { + error: { + code: 400, + message: ServerError.EMAIL_EXISTS, + errors: [ + { + message: ServerError.EMAIL_EXISTS + } + ] + } + }, + 400 + ); + const promise = _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.SIGN_UP, + request, + { + [ServerError.EMAIL_EXISTS]: AuthErrorCode.ARGUMENT_ERROR + } + ); + await expect(promise).to.be.rejectedWith( + FirebaseError, + 'Firebase: Error (auth/argument-error).' + ); + expect(mock.calls[0].request).to.eql(request); + }); + }); + + context('with network issues', () => { + let fetchStub: SinonStub; + + beforeEach(() => { + fetchStub = stub(self, 'fetch'); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + it('should handle timeouts', async () => { + const clock = useFakeTimers(); + fetchStub.callsFake(() => { + return new Promise(() => null); + }); + const promise = _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.SIGN_UP, + request + ); + clock.tick(DEFAULT_API_TIMEOUT_MS.get() + 1); + await expect(promise).to.be.rejectedWith( + FirebaseError, + 'Firebase: The operation has timed out. (auth/timeout).' + ); + clock.restore(); + }); + + it('should handle network failure', async () => { + fetchStub.callsFake(() => { + return new Promise((_, reject) => + reject(new Error('network error')) + ); + }); + const promise = _performApiRequest( + auth, + HttpMethod.POST, + Endpoint.SIGN_UP, + request + ); + 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).' + ); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/api/index.ts b/packages-exp/auth-exp/src/api/index.ts new file mode 100644 index 00000000000..952bfcc41ff --- /dev/null +++ b/packages-exp/auth-exp/src/api/index.ts @@ -0,0 +1,171 @@ +/** + * @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 { FirebaseError, querystring } from '@firebase/util'; + +import { AUTH_ERROR_FACTORY, AuthErrorCode } from '../core/errors'; +import { Delay } from '../core/util/delay'; +import { Auth } from '../model/auth'; +import { IdTokenResponse } from '../model/id_token'; +import { + JsonError, + SERVER_ERROR_MAP, + ServerError, + ServerErrorMap +} from './errors'; + +export enum HttpMethod { + POST = 'POST', + GET = 'GET' +} + +export enum Endpoint { + CREATE_AUTH_URI = '/v1/accounts:createAuthUri', + DELETE_ACCOUNT = '/v1/accounts:delete', + RESET_PASSWORD = '/v1/accounts:resetPassword', + SIGN_UP = '/v1/accounts:signUp', + SIGN_IN_WITH_CUSTOM_TOKEN = '/v1/accounts:signInWithCustomToken', + SIGN_IN_WITH_EMAIL_LINK = '/v1/accounts:signInWithEmailLink', + SIGN_IN_WITH_IDP = '/v1/accounts:signInWithIdp', + SIGN_IN_WITH_PASSWORD = '/v1/accounts:signInWithPassword', + SIGN_IN_WITH_PHONE_NUMBER = '/v1/accounts:signInWithPhoneNumber', + SEND_VERIFICATION_CODE = '/v1/accounts:sendVerificationCode', + SEND_OOB_CODE = '/v1/accounts:sendOobCode', + SET_ACCOUNT_INFO = '/v1/accounts:update', + GET_ACCOUNT_INFO = '/v1/accounts:lookup', + GET_RECAPTCHA_PARAM = '/v1/recaptchaParams', + START_PHONE_MFA_ENROLLMENT = '/v2/accounts/mfaEnrollment:start', + FINALIZE_PHONE_MFA_ENROLLMENT = '/v2/accounts/mfaEnrollment:finalize', + START_PHONE_MFA_SIGN_IN = '/v2/accounts/mfaSignIn:start', + FINALIZE_PHONE_MFA_SIGN_IN = '/v2/accounts/mfaSignIn:finalize', + WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw' +} + +export const DEFAULT_API_TIMEOUT_MS = new Delay(30_000, 60_000); + +export async function _performApiRequest( + auth: Auth, + method: HttpMethod, + path: Endpoint, + request?: T, + customErrorMap: Partial> = {} +): Promise { + return _performFetchWithErrorHandling(auth, customErrorMap, () => { + let body = {}; + let params = {}; + if (request) { + if (method === HttpMethod.GET) { + params = request; + } else { + body = { + body: JSON.stringify(request) + }; + } + } + + const query = querystring({ + key: auth.config.apiKey, + ...params + }).slice(1); + + return fetch( + `${auth.config.apiScheme}://${auth.config.apiHost}${path}?${query}`, + { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Client-Version': auth.config.sdkClientVersion + }, + referrerPolicy: 'no-referrer', + ...body + } + ); + }); +} + +export async function _performFetchWithErrorHandling( + auth: Auth, + customErrorMap: Partial>, + fetchFn: () => Promise +): Promise { + const errorMap = { ...SERVER_ERROR_MAP, ...customErrorMap }; + try { + const response: Response = await Promise.race>([ + fetchFn(), + makeNetworkTimeout(auth.name) + ]); + if (response.ok) { + return response.json(); + } else { + const json: JsonError = await response.json(); + const authError = errorMap[json.error.message]; + if (authError) { + throw AUTH_ERROR_FACTORY.create(authError, { appName: auth.name }); + } else { + // TODO probably should handle improperly formatted errors as well + // If you see this, add an entry to SERVER_ERROR_MAP for the corresponding error + console.error(`Unexpected API error: ${json.error.message}`); + throw AUTH_ERROR_FACTORY.create(AuthErrorCode.INTERNAL_ERROR, { + appName: auth.name + }); + } + } + } catch (e) { + if (e instanceof FirebaseError) { + throw e; + } + throw AUTH_ERROR_FACTORY.create(AuthErrorCode.NETWORK_REQUEST_FAILED, { + appName: auth.name + }); + } +} + +export async function _performSignInRequest( + auth: Auth, + method: HttpMethod, + path: Endpoint, + request?: T, + customErrorMap: Partial> = {} +): Promise { + const serverResponse = await _performApiRequest( + auth, + method, + path, + request, + customErrorMap + ); + if (serverResponse.mfaPendingCredential) { + throw AUTH_ERROR_FACTORY.create(AuthErrorCode.MFA_REQUIRED, { + appName: auth.name, + serverResponse + }); + } + + return serverResponse; +} + +function makeNetworkTimeout(appName: string): Promise { + return new Promise((_, reject) => + setTimeout(() => { + return reject( + AUTH_ERROR_FACTORY.create(AuthErrorCode.TIMEOUT, { + appName + }) + ); + }, DEFAULT_API_TIMEOUT_MS.get()) + ); +} diff --git a/packages-exp/auth-exp/src/core/action_code_url.test.ts b/packages-exp/auth-exp/src/core/action_code_url.test.ts new file mode 100644 index 00000000000..bb872ce3ed3 --- /dev/null +++ b/packages-exp/auth-exp/src/core/action_code_url.test.ts @@ -0,0 +1,169 @@ +/** + * @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 } from 'chai'; + +import { Operation } from '@firebase/auth-types-exp'; + +import { testAuth } from '../../test/mock_auth'; +import { Auth } from '../model/auth'; +import { ActionCodeURL } from './action_code_url'; + +describe('core/action_code_url', () => { + let auth: Auth; + beforeEach(async () => { + auth = await testAuth(); + }); + + describe('._fromLink', () => { + it('should parse correctly formatted links', () => { + const continueUrl = 'https://www.example.com/path/to/file?a=1&b=2#c=3'; + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=signIn&apiKey=API_KEY&' + + 'continueUrl=' + + encodeURIComponent(continueUrl) + + '&languageCode=en&tenantId=TENANT_ID&state=bla'; + const actionCodeUrl = ActionCodeURL.parseLink(auth, actionLink); + expect(actionCodeUrl!.operation).to.eq(Operation.EMAIL_SIGNIN); + expect(actionCodeUrl!.code).to.eq('CODE'); + expect(actionCodeUrl!.apiKey).to.eq('API_KEY'); + // ContinueUrl should be decoded. + expect(actionCodeUrl!.continueUrl).to.eq(continueUrl); + expect(actionCodeUrl!.tenantId).to.eq('TENANT_ID'); + expect(actionCodeUrl!.languageCode).to.eq('en'); + }); + + context('operation', () => { + it('should identitfy EMAIL_SIGNIN', () => { + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=signIn&apiKey=API_KEY&' + + 'languageCode=en'; + const actionCodeUrl = ActionCodeURL.parseLink(auth, actionLink); + expect(actionCodeUrl!.operation).to.eq(Operation.EMAIL_SIGNIN); + }); + + it('should identitfy VERIFY_AND_CHANGE_EMAIL', () => { + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=verifyAndChangeEmail&apiKey=API_KEY&' + + 'languageCode=en'; + const actionCodeUrl = ActionCodeURL.parseLink(auth, actionLink); + expect(actionCodeUrl!.operation).to.eq( + Operation.VERIFY_AND_CHANGE_EMAIL + ); + }); + + it('should identitfy VERIFY_EMAIL', () => { + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=verifyEmail&apiKey=API_KEY&' + + 'languageCode=en'; + const actionCodeUrl = ActionCodeURL.parseLink(auth, actionLink); + expect(actionCodeUrl!.operation).to.eq(Operation.VERIFY_EMAIL); + }); + + it('should identitfy RECOVER_EMAIL', () => { + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=recoverEmail&apiKey=API_KEY&' + + 'languageCode=en'; + const actionCodeUrl = ActionCodeURL.parseLink(auth, actionLink); + expect(actionCodeUrl!.operation).to.eq(Operation.RECOVER_EMAIL); + }); + + it('should identitfy PASSWORD_RESET', () => { + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=resetPassword&apiKey=API_KEY&' + + 'languageCode=en'; + const actionCodeUrl = ActionCodeURL.parseLink(auth, actionLink); + expect(actionCodeUrl!.operation).to.eq(Operation.PASSWORD_RESET); + }); + + it('should identitfy REVERT_SECOND_FACTOR_ADDITION', () => { + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=revertSecondFactorAddition&apiKey=API_KEY&' + + 'languageCode=en'; + const actionCodeUrl = ActionCodeURL.parseLink(auth, actionLink); + expect(actionCodeUrl!.operation).to.eq( + Operation.REVERT_SECOND_FACTOR_ADDITION + ); + }); + }); + + it('should work if there is a port number in the URL', () => { + const actionLink = + 'https://www.example.com:8080/finishSignIn?' + + 'oobCode=CODE&mode=signIn&apiKey=API_KEY&state=bla'; + const actionCodeUrl = ActionCodeURL.parseLink(auth, actionLink); + expect(actionCodeUrl!.operation).to.eq(Operation.EMAIL_SIGNIN); + expect(actionCodeUrl!.code).to.eq('CODE'); + expect(actionCodeUrl!.apiKey).to.eq('API_KEY'); + expect(actionCodeUrl!.continueUrl).to.be.null; + expect(actionCodeUrl!.tenantId).to.be.null; + expect(actionCodeUrl!.languageCode).to.be.null; + }); + + it('should ignore parameters after anchor', () => { + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE1&mode=signIn&apiKey=API_KEY1&state=bla' + + '#oobCode=CODE2&mode=signIn&apiKey=API_KEY2&state=bla'; + const actionCodeUrl = ActionCodeURL.parseLink(auth, actionLink); + expect(actionCodeUrl!.operation).to.eq(Operation.EMAIL_SIGNIN); + expect(actionCodeUrl!.code).to.eq('CODE1'); + expect(actionCodeUrl!.apiKey).to.eq('API_KEY1'); + expect(actionCodeUrl!.continueUrl).to.be.null; + expect(actionCodeUrl!.tenantId).to.be.null; + expect(actionCodeUrl!.languageCode).to.be.null; + }); + + context('invalid links', () => { + it('should handle missing API key, code & mode', () => { + const actionLink = 'https://www.example.com/finishSignIn'; + expect(ActionCodeURL.parseLink(auth, actionLink)).to.be.null; + }); + + it('should handle invalid mode', () => { + const actionLink = + 'https://www.example.com/finishSignIn?oobCode=CODE&mode=INVALID_MODE&apiKey=API_KEY'; + expect(ActionCodeURL.parseLink(auth, actionLink)).to.be.null; + }); + + it('should handle missing code', () => { + const actionLink = + 'https://www.example.com/finishSignIn?mode=signIn&apiKey=API_KEY'; + expect(ActionCodeURL.parseLink(auth, actionLink)).to.be.null; + }); + + it('should handle missing API key', () => { + const actionLink = + 'https://www.example.com/finishSignIn?oobCode=CODE&mode=signIn'; + expect(ActionCodeURL.parseLink(auth, actionLink)).to.be.null; + }); + + it('should handle missing mode', () => { + const actionLink = + 'https://www.example.com/finishSignIn?oobCode=CODE&apiKey=API_KEY'; + expect(ActionCodeURL.parseLink(auth, actionLink)).to.be.null; + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/action_code_url.ts b/packages-exp/auth-exp/src/core/action_code_url.ts new file mode 100644 index 00000000000..f715c63aeda --- /dev/null +++ b/packages-exp/auth-exp/src/core/action_code_url.ts @@ -0,0 +1,111 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; +import { AuthErrorCode, AUTH_ERROR_FACTORY } from './errors'; + +/** + * Enums for fields in URL query string. + * @enum {string} + */ +enum QueryField { + API_KEY = 'apiKey', + CODE = 'oobCode', + CONTINUE_URL = 'continueUrl', + LANGUAGE_CODE = 'languageCode', + MODE = 'mode', + TENANT_ID = 'tenantId' +} + +/** + * Map from mode string in action code URL to Action Code Info operation. + */ +const MODE_TO_OPERATION_MAP: { [key: string]: externs.Operation } = { + 'recoverEmail': externs.Operation.RECOVER_EMAIL, + 'resetPassword': externs.Operation.PASSWORD_RESET, + 'signIn': externs.Operation.EMAIL_SIGNIN, + 'verifyEmail': externs.Operation.VERIFY_EMAIL, + 'verifyAndChangeEmail': externs.Operation.VERIFY_AND_CHANGE_EMAIL, + 'revertSecondFactorAddition': externs.Operation.REVERT_SECOND_FACTOR_ADDITION +}; + +/** + * Maps the mode string in action code URL to Action Code Info operation. + */ +function parseMode(mode: string | null): externs.Operation | null { + return mode ? MODE_TO_OPERATION_MAP[mode] || null : null; +} + +function parseDeepLink(url: string): string { + const uri = new URL(url); + const link = uri.searchParams.get('link'); + // Double link case (automatic redirect). + const doubleDeepLink = link ? new URL(link).searchParams.get('link') : null; + // iOS custom scheme links. + const iOSDeepLink = uri.searchParams.get('deep_link_id'); + const iOSDoubleDeepLink = iOSDeepLink + ? new URL(iOSDeepLink).searchParams.get('link') + : null; + return iOSDoubleDeepLink || iOSDeepLink || doubleDeepLink || link || url; +} + +export class ActionCodeURL implements externs.ActionCodeURL { + readonly apiKey: string; + readonly code: string; + readonly continueUrl: string | null; + readonly languageCode: string | null; + readonly operation: externs.Operation; + readonly tenantId: string | null; + + constructor(auth: externs.Auth, actionLink: string) { + const uri = new URL(actionLink); + const apiKey = uri.searchParams.get(QueryField.API_KEY); + const code = uri.searchParams.get(QueryField.CODE); + const operation = parseMode(uri.searchParams.get(QueryField.MODE)); + // Validate API key, code and mode. + if (!apiKey || !code || !operation) { + throw AUTH_ERROR_FACTORY.create(AuthErrorCode.ARGUMENT_ERROR, { + appName: auth.name + }); + } + this.apiKey = apiKey; + this.operation = operation; + this.code = code; + this.continueUrl = uri.searchParams.get(QueryField.CONTINUE_URL); + this.languageCode = uri.searchParams.get(QueryField.LANGUAGE_CODE); + this.tenantId = uri.searchParams.get(QueryField.TENANT_ID); + } + + static parseLink( + auth: externs.Auth, + link: string + ): externs.ActionCodeURL | null { + const actionLink = parseDeepLink(link); + try { + return new ActionCodeURL(auth, actionLink); + } catch { + return null; + } + } +} + +export function parseActionCodeURL( + auth: externs.Auth, + link: string +): externs.ActionCodeURL | null { + return ActionCodeURL.parseLink(auth, link); +} diff --git a/packages-exp/auth-exp/src/core/auth/auth_impl.test.ts b/packages-exp/auth-exp/src/core/auth/auth_impl.test.ts new file mode 100644 index 00000000000..ac46ecc7526 --- /dev/null +++ b/packages-exp/auth-exp/src/core/auth/auth_impl.test.ts @@ -0,0 +1,330 @@ +/** + * @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 { FirebaseApp } from '@firebase/app-types-exp'; +import * as externs from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { testUser } from '../../../test/mock_auth'; +import { Auth } from '../../model/auth'; +import { User } from '../../model/user'; +import { Persistence } from '../persistence'; +import { browserLocalPersistence } from '../persistence/browser'; +import { inMemoryPersistence } from '../persistence/in_memory'; +import { PersistenceUserManager } from '../persistence/persistence_user_manager'; +import { _getClientVersion, ClientPlatform } from '../util/version'; +import { + DEFAULT_API_HOST, + DEFAULT_API_SCHEME, + DEFAULT_TOKEN_API_HOST, + initializeAuth +} from './auth_impl'; + +use(sinonChai); + +const FAKE_APP: FirebaseApp = { + name: 'test-app', + options: { + apiKey: 'api-key', + authDomain: 'auth-domain' + }, + automaticDataCollectionEnabled: false +}; + +describe('core/auth/auth_impl', () => { + let auth: Auth; + let persistenceStub: sinon.SinonStubbedInstance; + + beforeEach(() => { + persistenceStub = sinon.stub(inMemoryPersistence as Persistence); + auth = initializeAuth(FAKE_APP, { + persistence: inMemoryPersistence + }) as Auth; + }); + + afterEach(sinon.restore); + + describe('#updateCurrentUser', () => { + it('sets the field on the auth object', async () => { + const user = testUser(auth, 'uid'); + await auth.updateCurrentUser(user); + expect(auth.currentUser).to.eql(user); + }); + + it('orders async operations correctly', async () => { + const users = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => { + return testUser(auth, `${n}`); + }); + + persistenceStub.set.callsFake(() => { + return new Promise(resolve => { + // Force into the async flow to make this test actually meaningful + setTimeout(() => resolve(), 1); + }); + }); + + await Promise.all(users.map(u => auth.updateCurrentUser(u))); + for (let i = 0; i < 10; i++) { + expect(persistenceStub.set.getCall(i)).to.have.been.calledWith( + sinon.match.any, + users[i].toPlainObject() + ); + } + }); + + it('setting to null triggers a remove call', async () => { + await auth.updateCurrentUser(null); + expect(persistenceStub.remove).to.have.been.called; + }); + }); + + describe('#signOut', () => { + it('sets currentUser to null, calls remove', async () => { + await auth.updateCurrentUser(testUser(auth, 'test')); + await auth.signOut(); + expect(persistenceStub.remove).to.have.been.called; + expect(auth.currentUser).to.be.null; + }); + }); + + describe('#setPersistence', () => { + it('swaps underlying persistence', async () => { + const newPersistence = browserLocalPersistence as Persistence; + const newStub = sinon.stub(newPersistence); + persistenceStub.get.returns( + Promise.resolve(testUser(auth, 'test').toPlainObject()) + ); + + await auth.setPersistence(newPersistence); + expect(persistenceStub.get).to.have.been.called; + expect(persistenceStub.remove).to.have.been.called; + expect(newStub.set).to.have.been.calledWith( + sinon.match.any, + testUser(auth, 'test').toPlainObject() + ); + }); + }); + + describe('change listeners', () => { + // // Helpers to convert auth state change results to promise + // function onAuthStateChange(callback: NextFn) + + it('immediately calls authStateChange if initialization finished', done => { + const user = testUser(auth, 'uid'); + auth.currentUser = user; + auth._isInitialized = true; + auth.onAuthStateChanged(user => { + expect(user).to.eq(user); + done(); + }); + }); + + it('immediately calls idTokenChange if initialization finished', done => { + const user = testUser(auth, 'uid'); + auth.currentUser = user; + auth._isInitialized = true; + auth.onIdTokenChanged(user => { + expect(user).to.eq(user); + done(); + }); + }); + + it('immediate callback is done async', () => { + auth._isInitialized = true; + let callbackCalled = false; + auth.onIdTokenChanged(() => { + callbackCalled = true; + }); + + expect(callbackCalled).to.be.false; + }); + + describe('user logs in/out, tokens refresh', () => { + let user: User; + let authStateCallback: sinon.SinonSpy; + let idTokenCallback: sinon.SinonSpy; + + beforeEach(() => { + user = testUser(auth, 'uid'); + authStateCallback = sinon.spy(); + idTokenCallback = sinon.spy(); + }); + + context('initially currentUser is null', () => { + beforeEach(async () => { + auth.onAuthStateChanged(authStateCallback); + auth.onIdTokenChanged(idTokenCallback); + await auth.updateCurrentUser(null); + authStateCallback.resetHistory(); + idTokenCallback.resetHistory(); + }); + + it('onAuthStateChange triggers on log in', async () => { + await auth.updateCurrentUser(user); + expect(authStateCallback).to.have.been.calledWith(user); + }); + + it('onIdTokenChange triggers on log in', async () => { + await auth.updateCurrentUser(user); + expect(idTokenCallback).to.have.been.calledWith(user); + }); + }); + + context('initially currentUser is user', () => { + beforeEach(async () => { + auth.onAuthStateChanged(authStateCallback); + auth.onIdTokenChanged(idTokenCallback); + await auth.updateCurrentUser(user); + authStateCallback.resetHistory(); + idTokenCallback.resetHistory(); + }); + + it('onAuthStateChange triggers on log out', async () => { + await auth.updateCurrentUser(null); + expect(authStateCallback).to.have.been.calledWith(null); + }); + + it('onIdTokenChange triggers on log out', async () => { + await auth.updateCurrentUser(null); + expect(idTokenCallback).to.have.been.calledWith(null); + }); + + it('onAuthStateChange does not trigger for user props change', async () => { + user.photoURL = 'blah'; + await auth.updateCurrentUser(user); + expect(authStateCallback).not.to.have.been.called; + }); + + it('onIdTokenChange triggers for user props change', async () => { + user.photoURL = 'hey look I changed'; + await auth.updateCurrentUser(user); + expect(idTokenCallback).to.have.been.calledWith(user); + }); + + it('onAuthStateChange triggers if uid changes', async () => { + const newUser = testUser(auth, 'different-uid'); + await auth.updateCurrentUser(newUser); + expect(authStateCallback).to.have.been.calledWith(newUser); + }); + }); + + it('onAuthStateChange works for multiple listeners', async () => { + const cb1 = sinon.spy(); + const cb2 = sinon.spy(); + auth.onAuthStateChanged(cb1); + auth.onAuthStateChanged(cb2); + await auth.updateCurrentUser(null); + cb1.resetHistory(); + cb2.resetHistory(); + + await auth.updateCurrentUser(user); + expect(cb1).to.have.been.calledWith(user); + expect(cb2).to.have.been.calledWith(user); + }); + + it('onIdTokenChange works for multiple listeners', async () => { + const cb1 = sinon.spy(); + const cb2 = sinon.spy(); + auth.onIdTokenChanged(cb1); + auth.onIdTokenChanged(cb2); + await auth.updateCurrentUser(null); + cb1.resetHistory(); + cb2.resetHistory(); + + await auth.updateCurrentUser(user); + expect(cb1).to.have.been.calledWith(user); + expect(cb2).to.have.been.calledWith(user); + }); + }); + }); +}); + +describe('core/auth/initializeAuth', () => { + afterEach(sinon.restore); + + it('throws an API error if key not provided', () => { + expect(() => + initializeAuth({ + ...FAKE_APP, + options: {} // apiKey is missing + }) + ).to.throw( + FirebaseError, + 'Firebase: Your API key is invalid, please check you have copied it correctly. (auth/invalid-api-key).' + ); + }); + + describe('persistence manager creation', () => { + let createManagerStub: sinon.SinonSpy; + beforeEach(() => { + createManagerStub = sinon.spy(PersistenceUserManager, 'create'); + }); + + async function initAndWait( + persistence: externs.Persistence | externs.Persistence[] + ): Promise { + const auth = initializeAuth(FAKE_APP, { persistence }); + // Auth initializes async. We can make sure the initialization is + // flushed by awaiting a method on the queue. + await auth.setPersistence(inMemoryPersistence); + return auth as Auth; + } + + it('converts single persistence to array', async () => { + const auth = await initAndWait(inMemoryPersistence); + expect(createManagerStub).to.have.been.calledWith(auth, [ + inMemoryPersistence + ]); + }); + + it('pulls the user from storage', async () => { + sinon + .stub(inMemoryPersistence as Persistence, 'get') + .returns(Promise.resolve(testUser({}, 'uid').toPlainObject())); + const auth = await initAndWait(inMemoryPersistence); + expect(auth.currentUser!.uid).to.eq('uid'); + }); + + it('calls create with the persistence in order', async () => { + const auth = await initAndWait([ + inMemoryPersistence, + browserLocalPersistence + ]); + expect(createManagerStub).to.have.been.calledWith(auth, [ + inMemoryPersistence, + browserLocalPersistence + ]); + }); + + it('sets auth name and config', async () => { + const auth = await initAndWait(inMemoryPersistence); + expect(auth.name).to.eq(FAKE_APP.name); + expect(auth.config).to.eql({ + apiKey: FAKE_APP.options.apiKey, + authDomain: FAKE_APP.options.authDomain, + apiHost: DEFAULT_API_HOST, + apiScheme: DEFAULT_API_SCHEME, + tokenApiHost: DEFAULT_TOKEN_API_HOST, + sdkClientVersion: _getClientVersion(ClientPlatform.BROWSER) + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/auth/auth_impl.ts b/packages-exp/auth-exp/src/core/auth/auth_impl.ts new file mode 100644 index 00000000000..bd05d26a446 --- /dev/null +++ b/packages-exp/auth-exp/src/core/auth/auth_impl.ts @@ -0,0 +1,254 @@ +/** + * @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 { getApp } from '@firebase/app-exp'; +import { FirebaseApp } from '@firebase/app-types-exp'; +import * as externs from '@firebase/auth-types-exp'; +import { + CompleteFn, + createSubscribe, + ErrorFn, + NextFn, + Observer, + Subscribe, + Unsubscribe +} from '@firebase/util'; + +import { Auth, Dependencies } from '../../model/auth'; +import { User } from '../../model/user'; +import { AuthErrorCode } from '../errors'; +import { Persistence } from '../persistence'; +import { PersistenceUserManager } from '../persistence/persistence_user_manager'; +import { assert } from '../util/assert'; +import { _getClientVersion, ClientPlatform } from '../util/version'; + +interface AsyncAction { + (): Promise; +} + +export const DEFAULT_TOKEN_API_HOST = 'securetoken.googleapis.com'; +export const DEFAULT_API_HOST = 'identitytoolkit.googleapis.com'; +export const DEFAULT_API_SCHEME = 'https'; + +export class AuthImpl implements Auth { + currentUser: User | null = null; + private operations = Promise.resolve(); + private persistenceManager?: PersistenceUserManager; + private authStateSubscription = new Subscription(this); + private idTokenSubscription = new Subscription(this); + _isInitialized = false; + + // Tracks the last notified UID for state change listeners to prevent + // repeated calls to the callbacks + private lastNotifiedUid: string | undefined = undefined; + + languageCode: string | null = null; + tenantId: string | null = null; + settings: externs.AuthSettings = { appVerificationDisabledForTesting: false }; + + constructor( + public readonly name: string, + public readonly config: externs.Config + ) {} + + _initializeWithPersistence( + persistenceHierarchy: Persistence[] + ): Promise { + return this.queue(async () => { + this.persistenceManager = await PersistenceUserManager.create( + this, + persistenceHierarchy + ); + + const storedUser = await this.persistenceManager.getCurrentUser(); + // TODO: Check redirect user, if not redirect user, call refresh on stored user + if (storedUser) { + await this.directlySetCurrentUser(storedUser); + } + + this._isInitialized = true; + this.notifyAuthListeners(); + }); + } + + useDeviceLanguage(): void { + throw new Error('Method not implemented.'); + } + + async updateCurrentUser(user: User | null): Promise { + return this.queue(async () => { + await this.directlySetCurrentUser(user); + this.notifyAuthListeners(); + }); + } + + async signOut(): Promise { + return this.updateCurrentUser(null); + } + + setPersistence(persistence: Persistence): Promise { + return this.queue(async () => { + await this.assertedPersistence.setPersistence(persistence); + }); + } + + onAuthStateChanged( + nextOrObserver: externs.NextOrObserver, + error?: ErrorFn, + completed?: CompleteFn + ): Unsubscribe { + return this.registerStateListener( + this.authStateSubscription, + nextOrObserver, + error, + completed + ); + } + + onIdTokenChanged( + nextOrObserver: externs.NextOrObserver, + error?: ErrorFn, + completed?: CompleteFn + ): Unsubscribe { + return this.registerStateListener( + this.idTokenSubscription, + nextOrObserver, + error, + completed + ); + } + + async _persistUserIfCurrent(user: User): Promise { + if (user === this.currentUser) { + return this.queue(async () => this.directlySetCurrentUser(user)); + } + } + + /** Notifies listeners only if the user is current */ + _notifyListenersIfCurrent(user: User): void { + if (user === this.currentUser) { + this.notifyAuthListeners(); + } + } + + private notifyAuthListeners(): void { + if (!this._isInitialized) { + return; + } + + this.idTokenSubscription.next(this.currentUser); + + if (this.lastNotifiedUid !== this.currentUser?.uid) { + this.lastNotifiedUid = this.currentUser?.uid; + this.authStateSubscription.next(this.currentUser); + } + } + + private registerStateListener( + subscription: Subscription, + nextOrObserver: externs.NextOrObserver, + error?: ErrorFn, + completed?: CompleteFn + ): Unsubscribe { + if (this._isInitialized) { + const cb = + typeof nextOrObserver === 'function' + ? nextOrObserver + : nextOrObserver.next; + // The callback needs to be called asynchronously per the spec. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve().then(() => cb(this.currentUser)); + } + + if (typeof nextOrObserver === 'function') { + return subscription.addObserver(nextOrObserver, error, completed); + } else { + return subscription.addObserver(nextOrObserver); + } + } + + /** + * Unprotected (from race conditions) method to set the current user. This + * should only be called from within a queued callback. This is necessary + * because the queue shouldn't rely on another queued callback. + */ + private async directlySetCurrentUser(user: User | null): Promise { + this.currentUser = user; + + if (user) { + await this.assertedPersistence.setCurrentUser(user); + } else { + await this.assertedPersistence.removeCurrentUser(); + } + } + + private queue(action: AsyncAction): Promise { + // In case something errors, the callback still should be called in order + // to keep the promise chain alive + this.operations = this.operations.then(action, action); + return this.operations; + } + + private get assertedPersistence(): PersistenceUserManager { + assert(this.persistenceManager, this.name); + return this.persistenceManager; + } +} + +export function initializeAuth( + app: FirebaseApp = getApp(), + deps?: Dependencies +): externs.Auth { + const persistence = deps?.persistence || []; + const hierarchy = Array.isArray(persistence) ? persistence : [persistence]; + const { apiKey, authDomain } = app.options; + + // TODO: platform needs to be determined using heuristics + assert(apiKey, app.name, AuthErrorCode.INVALID_API_KEY); + const config: externs.Config = { + apiKey, + authDomain, + apiHost: DEFAULT_API_HOST, + tokenApiHost: DEFAULT_TOKEN_API_HOST, + apiScheme: DEFAULT_API_SCHEME, + sdkClientVersion: _getClientVersion(ClientPlatform.BROWSER) + }; + + const auth = new AuthImpl(app.name, config); + + // This promise is intended to float; auth initialization happens in the + // background, meanwhile the auth object may be used by the app. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + auth._initializeWithPersistence(hierarchy as Persistence[]); + + return auth; +} + +/** Helper class to wrap subscriber logic */ +class Subscription { + private observer: Observer | null = null; + readonly addObserver: Subscribe = createSubscribe( + observer => (this.observer = observer) + ); + + constructor(readonly auth: Auth) {} + + get next(): NextFn { + assert(this.observer, this.auth.name); + return this.observer.next.bind(this.observer); + } +} diff --git a/packages-exp/auth-exp/src/core/credentials/anonymous.test.ts b/packages-exp/auth-exp/src/core/credentials/anonymous.test.ts new file mode 100644 index 00000000000..70be29bdf7c --- /dev/null +++ b/packages-exp/auth-exp/src/core/credentials/anonymous.test.ts @@ -0,0 +1,96 @@ +/** + * @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 { ProviderId, SignInMethod } from '@firebase/auth-types-exp'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { APIUserInfo } from '../../api/account_management/account'; +import { Auth } from '../../model/auth'; +import { AnonymousCredential } from './anonymous'; + +use(chaiAsPromised); + +describe('core/credentials/anonymous', () => { + let auth: Auth; + let credential: AnonymousCredential; + + beforeEach(async () => { + auth = await testAuth(); + credential = new AnonymousCredential(); + }); + + it('should have an anonymous provider', () => { + expect(credential.providerId).to.eq(ProviderId.ANONYMOUS); + }); + + it('should have an anonymous sign in method', () => { + expect(credential.signInMethod).to.eq(SignInMethod.ANONYMOUS); + }); + + describe('#toJSON', () => { + it('throws', () => { + expect(credential.toJSON).to.throw(Error); + }); + }); + + describe('#_getIdTokenResponse', () => { + const serverUser: APIUserInfo = { + localId: 'local-id' + }; + + beforeEach(() => { + mockFetch.setUp(); + mockEndpoint(Endpoint.SIGN_UP, { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + }); + afterEach(mockFetch.tearDown); + + it('calls signUp', async () => { + const idTokenResponse = await credential._getIdTokenResponse(auth); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + }); + }); + + describe('#_linkToIdToken', () => { + it('throws', async () => { + await expect( + credential._linkToIdToken(auth, 'id-token') + ).to.be.rejectedWith(Error); + }); + }); + + describe('#_getReauthenticationResolver', () => { + it('throws', () => { + expect(() => credential._getReauthenticationResolver(auth)).to.throw( + Error + ); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/credentials/anonymous.ts b/packages-exp/auth-exp/src/core/credentials/anonymous.ts new file mode 100644 index 00000000000..40681d3bdb2 --- /dev/null +++ b/packages-exp/auth-exp/src/core/credentials/anonymous.ts @@ -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 { ProviderId, SignInMethod } from '@firebase/auth-types-exp'; + +import { signUp } from '../../api/authentication/sign_up'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { debugFail } from '../util/assert'; +import { AuthCredential } from './'; + +export class AnonymousCredential implements AuthCredential { + providerId = ProviderId.ANONYMOUS; + signInMethod = SignInMethod.ANONYMOUS; + + toJSON(): never { + debugFail('Method not implemented.'); + } + + static fromJSON(_json: object | string): AnonymousCredential | null { + debugFail('Method not implemented'); + } + + async _getIdTokenResponse(auth: Auth): Promise { + return signUp(auth, { + returnSecureToken: true + }); + } + + async _linkToIdToken(_auth: Auth, _idToken: string): Promise { + debugFail("Can't link to an anonymous credential"); + } + + _getReauthenticationResolver(_auth: Auth): Promise { + debugFail('Method not implemented.'); + } +} diff --git a/packages-exp/auth-exp/src/core/credentials/email.test.ts b/packages-exp/auth-exp/src/core/credentials/email.test.ts new file mode 100644 index 00000000000..4cc4727d132 --- /dev/null +++ b/packages-exp/auth-exp/src/core/credentials/email.test.ts @@ -0,0 +1,212 @@ +/** + * @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 { ProviderId, SignInMethod } from '@firebase/auth-types-exp'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { APIUserInfo } from '../../api/account_management/account'; +import { Auth } from '../../model/auth'; +import { EmailAuthProvider } from '../providers/email'; +import { EmailAuthCredential } from './email'; + +use(chaiAsPromised); + +describe('core/credentials/email', () => { + let auth: Auth; + let apiMock: mockFetch.Route; + const serverUser: APIUserInfo = { + localId: 'local-id' + }; + + beforeEach(async () => { + auth = await testAuth(); + }); + + context('email & password', () => { + const credential = new EmailAuthCredential( + 'some-email', + 'some-password', + EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD + ); + + beforeEach(() => { + mockFetch.setUp(); + apiMock = mockEndpoint(Endpoint.SIGN_IN_WITH_PASSWORD, { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + }); + afterEach(mockFetch.tearDown); + + it('should have an email provider', () => { + expect(credential.providerId).to.eq(ProviderId.PASSWORD); + }); + + it('should have an anonymous sign in method', () => { + expect(credential.signInMethod).to.eq(SignInMethod.EMAIL_PASSWORD); + }); + + describe('#toJSON', () => { + it('throws', () => { + expect(credential.toJSON).to.throw(Error); + }); + }); + + describe('#_getIdTokenResponse', () => { + it('calls sign in with password', async () => { + const idTokenResponse = await credential._getIdTokenResponse(auth); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + expect(apiMock.calls[0].request).to.eql({ + returnSecureToken: true, + email: 'some-email', + password: 'some-password' + }); + }); + }); + + describe('#_linkToIdToken', () => { + it('calls update email password', async () => { + apiMock = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + + const idTokenResponse = await credential._linkToIdToken( + auth, + 'id-token-2' + ); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + expect(apiMock.calls[0].request).to.eql({ + idToken: 'id-token-2', + returnSecureToken: true, + email: 'some-email', + password: 'some-password' + }); + }); + }); + + describe('#_getReauthenticationResolver', () => { + it('calls sign in with password', async () => { + const idTokenResponse = await credential._getIdTokenResponse(auth); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + expect(apiMock.calls[0].request).to.eql({ + returnSecureToken: true, + email: 'some-email', + password: 'some-password' + }); + }); + }); + }); + + context('email link', () => { + const credential = new EmailAuthCredential( + 'some-email', + 'oob-code', + EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD + ); + + beforeEach(() => { + mockFetch.setUp(); + apiMock = mockEndpoint(Endpoint.SIGN_IN_WITH_EMAIL_LINK, { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + }); + afterEach(mockFetch.tearDown); + + it('should have an email provider', () => { + expect(credential.providerId).to.eq(ProviderId.PASSWORD); + }); + + it('should have an anonymous sign in method', () => { + expect(credential.signInMethod).to.eq(SignInMethod.EMAIL_LINK); + }); + + describe('#toJSON', () => { + it('throws', () => { + expect(credential.toJSON).to.throw(Error); + }); + }); + + describe('#_getIdTokenResponse', () => { + it('call sign in with email link', async () => { + const idTokenResponse = await credential._getIdTokenResponse(auth); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + expect(apiMock.calls[0].request).to.eql({ + email: 'some-email', + oobCode: 'oob-code' + }); + }); + }); + + describe('#_linkToIdToken', () => { + it('calls sign in with the new token', async () => { + const idTokenResponse = await credential._linkToIdToken( + auth, + 'id-token-2' + ); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + expect(apiMock.calls[0].request).to.eql({ + idToken: 'id-token-2', + email: 'some-email', + oobCode: 'oob-code' + }); + }); + }); + + describe('#_matchIdTokenWithUid', () => { + it('call sign in with email link', async () => { + const idTokenResponse = await credential._getIdTokenResponse(auth); + expect(idTokenResponse.idToken).to.eq('id-token'); + expect(idTokenResponse.refreshToken).to.eq('refresh-token'); + expect(idTokenResponse.expiresIn).to.eq('1234'); + expect(idTokenResponse.localId).to.eq(serverUser.localId); + expect(apiMock.calls[0].request).to.eql({ + email: 'some-email', + oobCode: 'oob-code' + }); + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/credentials/email.ts b/packages-exp/auth-exp/src/core/credentials/email.ts new file mode 100644 index 00000000000..a4dee25afa3 --- /dev/null +++ b/packages-exp/auth-exp/src/core/credentials/email.ts @@ -0,0 +1,91 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { updateEmailPassword } from '../../api/account_management/email_and_password'; +import { signInWithPassword } from '../../api/authentication/email_and_password'; +import { + signInWithEmailLink, + signInWithEmailLinkForLinking +} from '../../api/authentication/email_link'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { AuthErrorCode } from '../errors'; +import { EmailAuthProvider } from '../providers/email'; +import { debugFail, fail } from '../util/assert'; +import { AuthCredential } from './'; + +export class EmailAuthCredential implements AuthCredential { + readonly providerId = EmailAuthProvider.PROVIDER_ID; + + constructor( + readonly email: string, + readonly password: string, + readonly signInMethod: externs.SignInMethod + ) {} + + toJSON(): never { + debugFail('Method not implemented.'); + } + + static fromJSON(_json: object | string): EmailAuthCredential | null { + debugFail('Method not implemented'); + } + + async _getIdTokenResponse(auth: Auth): Promise { + switch (this.signInMethod) { + case EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD: + return signInWithPassword(auth, { + returnSecureToken: true, + email: this.email, + password: this.password + }); + case EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD: + return signInWithEmailLink(auth, { + email: this.email, + oobCode: this.password + }); + default: + fail(auth.name, AuthErrorCode.INTERNAL_ERROR); + } + } + + async _linkToIdToken(auth: Auth, idToken: string): Promise { + switch (this.signInMethod) { + case EmailAuthProvider.EMAIL_PASSWORD_SIGN_IN_METHOD: + return updateEmailPassword(auth, { + idToken, + returnSecureToken: true, + email: this.email, + password: this.password + }); + case EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD: + return signInWithEmailLinkForLinking(auth, { + idToken, + email: this.email, + oobCode: this.password + }); + default: + fail(auth.name, AuthErrorCode.INTERNAL_ERROR); + } + } + + _getReauthenticationResolver(auth: Auth): Promise { + return this._getIdTokenResponse(auth); + } +} diff --git a/packages-exp/auth-exp/src/core/credentials/index.d.ts b/packages-exp/auth-exp/src/core/credentials/index.d.ts new file mode 100644 index 00000000000..3ffa8da5ea0 --- /dev/null +++ b/packages-exp/auth-exp/src/core/credentials/index.d.ts @@ -0,0 +1,30 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { PhoneOrOauthTokenResponse } from '../../api/authentication/mfa'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; + +export abstract class AuthCredential extends externs.AuthCredential { + static fromJSON(json: object | string): AuthCredential | null; + + _getIdTokenResponse(auth: Auth): Promise; + _linkToIdToken(auth: Auth, idToken: string): Promise; + _getReauthenticationResolver(auth: Auth): Promise; +} diff --git a/packages-exp/auth-exp/src/core/credentials/phone.test.ts b/packages-exp/auth-exp/src/core/credentials/phone.test.ts new file mode 100644 index 00000000000..998cec04067 --- /dev/null +++ b/packages-exp/auth-exp/src/core/credentials/phone.test.ts @@ -0,0 +1,183 @@ +/** + * @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 } from 'chai'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { PhoneAuthCredential } from '../credentials/phone'; + +describe('core/credentials/phone', () => { + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + fetch.setUp(); + }); + + afterEach(() => { + fetch.tearDown(); + }); + + context('#_getIdTokenResponse', () => { + const response: IdTokenResponse = { + idToken: '', + refreshToken: '', + kind: '', + expiresIn: '10', + localId: '' + }; + + it('calls the endpoint with session and code', async () => { + const cred = new PhoneAuthCredential({ + verificationId: 'session-info', + verificationCode: 'code' + }); + + const route = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, response); + + expect(await cred._getIdTokenResponse(auth)).to.eql(response); + expect(route.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: 'code' + }); + }); + + it('calls the endpoint with proof and number', async () => { + const cred = new PhoneAuthCredential({ + temporaryProof: 'temp-proof', + phoneNumber: 'number' + }); + + const route = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, response); + + expect(await cred._getIdTokenResponse(auth)).to.eql(response); + expect(route.calls[0].request).to.eql({ + temporaryProof: 'temp-proof', + phoneNumber: 'number' + }); + }); + }); + + context('#_linkToIdToken', () => { + const response: IdTokenResponse = { + idToken: '', + refreshToken: '', + kind: '', + expiresIn: '10', + localId: 'uid' + }; + + it('calls the endpoint with session and code', async () => { + const cred = new PhoneAuthCredential({ + verificationId: 'session-info', + verificationCode: 'code' + }); + + const route = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, response); + + expect(await cred._linkToIdToken(auth, 'id-token')).to.eql(response); + expect(route.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: 'code', + idToken: 'id-token' + }); + }); + + it('calls the endpoint with proof and number', async () => { + const cred = new PhoneAuthCredential({ + temporaryProof: 'temp-proof', + phoneNumber: 'number' + }); + + const route = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, response); + + expect(await cred._linkToIdToken(auth, 'id-token')).to.eql(response); + expect(route.calls[0].request).to.eql({ + temporaryProof: 'temp-proof', + phoneNumber: 'number', + idToken: 'id-token' + }); + }); + }); + + context('#toJSON', () => { + it('fills out the object with everything that is set', () => { + const cred = new PhoneAuthCredential({ + temporaryProof: 'proof', + phoneNumber: 'number', + verificationId: 'id', + verificationCode: 'code' + }); + + expect(cred.toJSON()).to.eql({ + providerId: 'phone', + temporaryProof: 'proof', + phoneNumber: 'number', + verificationId: 'id', + verificationCode: 'code' + }); + }); + + it('omits missing fields', () => { + const cred = new PhoneAuthCredential({ + temporaryProof: 'proof', + phoneNumber: 'number' + }); + + expect(cred.toJSON()).to.eql({ + providerId: 'phone', + temporaryProof: 'proof', + phoneNumber: 'number' + }); + }); + }); + + context('.fromJSON', () => { + it('works if passed a string', () => { + const cred = PhoneAuthCredential.fromJSON('{"phoneNumber": "number"}'); + expect(cred?.toJSON()).to.eql({ + providerId: 'phone', + phoneNumber: 'number' + }); + }); + + it('works if passed an object', () => { + const cred = PhoneAuthCredential.fromJSON({ + temporaryProof: 'proof', + phoneNumber: 'number', + verificationId: 'id', + verificationCode: 'code' + }); + expect(cred?.toJSON()).to.eql({ + providerId: 'phone', + temporaryProof: 'proof', + phoneNumber: 'number', + verificationId: 'id', + verificationCode: 'code' + }); + }); + + it('returns null if object contains no matching fields', () => { + expect(PhoneAuthCredential.fromJSON({})).to.be.null; + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/credentials/phone.ts b/packages-exp/auth-exp/src/core/credentials/phone.ts new file mode 100644 index 00000000000..768ce056dc5 --- /dev/null +++ b/packages-exp/auth-exp/src/core/credentials/phone.ts @@ -0,0 +1,124 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { PhoneOrOauthTokenResponse } from '../../api/authentication/mfa'; +import { + linkWithPhoneNumber, + signInWithPhoneNumber, + SignInWithPhoneNumberRequest, + verifyPhoneNumberForExisting +} from '../../api/authentication/sms'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { AuthCredential } from './'; + +export interface PhoneAuthCredentialParameters { + verificationId?: string; + verificationCode?: string; + phoneNumber?: string; + temporaryProof?: string; +} + +export class PhoneAuthCredential + implements AuthCredential, externs.PhoneAuthCredential { + readonly providerId = externs.ProviderId.PHONE; + readonly signInMethod = externs.SignInMethod.PHONE; + + constructor(private readonly params: PhoneAuthCredentialParameters) {} + + _getIdTokenResponse(auth: Auth): Promise { + return signInWithPhoneNumber(auth, this.makeVerificationRequest()); + } + + _linkToIdToken(auth: Auth, idToken: string): Promise { + return linkWithPhoneNumber(auth, { + idToken, + ...this.makeVerificationRequest() + }); + } + + _getReauthenticationResolver(auth: Auth): Promise { + return verifyPhoneNumberForExisting(auth, this.makeVerificationRequest()); + } + + private makeVerificationRequest(): SignInWithPhoneNumberRequest { + const { + temporaryProof, + phoneNumber, + verificationId, + verificationCode + } = this.params; + if (temporaryProof && phoneNumber) { + return { temporaryProof, phoneNumber }; + } + + return { + sessionInfo: verificationId, + code: verificationCode + }; + } + + toJSON(): object { + const obj: Record = { + providerId: this.providerId + }; + if (this.params.phoneNumber) { + obj.phoneNumber = this.params.phoneNumber; + } + if (this.params.temporaryProof) { + obj.temporaryProof = this.params.temporaryProof; + } + if (this.params.verificationCode) { + obj.verificationCode = this.params.verificationCode; + } + if (this.params.verificationId) { + obj.verificationId = this.params.verificationId; + } + + return obj; + } + + static fromJSON(json: object | string): PhoneAuthCredential | null { + if (typeof json === 'string') { + json = JSON.parse(json); + } + + const { + verificationId, + verificationCode, + phoneNumber, + temporaryProof + } = json as { [key: string]: string }; + if ( + !verificationCode && + !verificationId && + !phoneNumber && + !temporaryProof + ) { + return null; + } + + return new PhoneAuthCredential({ + verificationId, + verificationCode, + phoneNumber, + temporaryProof + }); + } +} diff --git a/packages-exp/auth-exp/src/core/errors.test.ts b/packages-exp/auth-exp/src/core/errors.test.ts new file mode 100644 index 00000000000..cfab7d39a1e --- /dev/null +++ b/packages-exp/auth-exp/src/core/errors.test.ts @@ -0,0 +1,32 @@ +/** + * @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 } from 'chai'; +import { AuthErrorCode, AUTH_ERROR_FACTORY } from './errors'; + +describe('core/AUTH_ERROR_FACTORY', () => { + it('should create an Auth namespaced FirebaseError', () => { + const error = AUTH_ERROR_FACTORY.create(AuthErrorCode.INTERNAL_ERROR, { + appName: 'my-app' + }); + expect(error.code).to.eq('auth/internal-error'); + expect(error.message).to.eq( + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + expect(error.name).to.eq('FirebaseError'); + }); +}); diff --git a/packages-exp/auth-exp/src/core/errors.ts b/packages-exp/auth-exp/src/core/errors.ts new file mode 100644 index 00000000000..6b1de2da373 --- /dev/null +++ b/packages-exp/auth-exp/src/core/errors.ts @@ -0,0 +1,318 @@ +/** + * @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. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { ErrorFactory, ErrorMap } from '@firebase/util'; +import { AppName } from '../model/auth'; +import { User } from '../model/user'; + +/* + * Developer facing Firebase Auth error codes. + */ +export const enum AuthErrorCode { + ADMIN_ONLY_OPERATION = 'admin-restricted-operation', + ARGUMENT_ERROR = 'argument-error', + APP_NOT_AUTHORIZED = 'app-not-authorized', + APP_NOT_INSTALLED = 'app-not-installed', + CAPTCHA_CHECK_FAILED = 'captcha-check-failed', + CODE_EXPIRED = 'code-expired', + CORDOVA_NOT_READY = 'cordova-not-ready', + CORS_UNSUPPORTED = 'cors-unsupported', + CREDENTIAL_ALREADY_IN_USE = 'credential-already-in-use', + CREDENTIAL_MISMATCH = 'custom-token-mismatch', + CREDENTIAL_TOO_OLD_LOGIN_AGAIN = 'requires-recent-login', + DYNAMIC_LINK_NOT_ACTIVATED = 'dynamic-link-not-activated', + EMAIL_EXISTS = 'email-already-in-use', + EXPIRED_OOB_CODE = 'expired-action-code', + EXPIRED_POPUP_REQUEST = 'cancelled-popup-request', + INTERNAL_ERROR = 'internal-error', + INVALID_API_KEY = 'invalid-api-key', + INVALID_APP_CREDENTIAL = 'invalid-app-credential', + INVALID_APP_ID = 'invalid-app-id', + INVALID_AUTH = 'invalid-user-token', + INVALID_AUTH_EVENT = 'invalid-auth-event', + INVALID_CERT_HASH = 'invalid-cert-hash', + INVALID_CODE = 'invalid-verification-code', + INVALID_CONTINUE_URI = 'invalid-continue-uri', + INVALID_CORDOVA_CONFIGURATION = 'invalid-cordova-configuration', + INVALID_CUSTOM_TOKEN = 'invalid-custom-token', + INVALID_DYNAMIC_LINK_DOMAIN = 'invalid-dynamic-link-domain', + INVALID_EMAIL = 'invalid-email', + INVALID_IDP_RESPONSE = 'invalid-credential', + INVALID_MESSAGE_PAYLOAD = 'invalid-message-payload', + INVALID_OAUTH_CLIENT_ID = 'invalid-oauth-client-id', + INVALID_OAUTH_PROVIDER = 'invalid-oauth-provider', + INVALID_OOB_CODE = 'invalid-action-code', + INVALID_ORIGIN = 'unauthorized-domain', + INVALID_PASSWORD = 'wrong-password', + INVALID_PERSISTENCE = 'invalid-persistence-type', + INVALID_PHONE_NUMBER = 'invalid-phone-number', + INVALID_PROVIDER_ID = 'invalid-provider-id', + INVALID_RECIPIENT_EMAIL = 'invalid-recipient-email', + INVALID_SENDER = 'invalid-sender', + INVALID_SESSION_INFO = 'invalid-verification-id', + INVALID_TENANT_ID = 'invalid-tenant-id', + MISSING_ANDROID_PACKAGE_NAME = 'missing-android-pkg-name', + MISSING_APP_CREDENTIAL = 'missing-app-credential', + MISSING_AUTH_DOMAIN = 'auth-domain-config-required', + MISSING_CODE = 'missing-verification-code', + MISSING_CONTINUE_URI = 'missing-continue-uri', + MISSING_IFRAME_START = 'missing-iframe-start', + MISSING_IOS_BUNDLE_ID = 'missing-ios-bundle-id', + MISSING_OR_INVALID_NONCE = 'missing-or-invalid-nonce', + MISSING_PHONE_NUMBER = 'missing-phone-number', + MISSING_SESSION_INFO = 'missing-verification-id', + MODULE_DESTROYED = 'app-deleted', + MFA_REQUIRED = 'multi-factor-auth-required', + NEED_CONFIRMATION = 'account-exists-with-different-credential', + NETWORK_REQUEST_FAILED = 'network-request-failed', + NULL_USER = 'null-user', + NO_AUTH_EVENT = 'no-auth-event', + NO_SUCH_PROVIDER = 'no-such-provider', + OPERATION_NOT_ALLOWED = 'operation-not-allowed', + OPERATION_NOT_SUPPORTED = 'operation-not-supported-in-this-environment', + POPUP_BLOCKED = 'popup-blocked', + POPUP_CLOSED_BY_USER = 'popup-closed-by-user', + PROVIDER_ALREADY_LINKED = 'provider-already-linked', + QUOTA_EXCEEDED = 'quota-exceeded', + REDIRECT_CANCELLED_BY_USER = 'redirect-cancelled-by-user', + REDIRECT_OPERATION_PENDING = 'redirect-operation-pending', + REJECTED_CREDENTIAL = 'rejected-credential', + TENANT_ID_MISMATCH = 'tenant-id-mismatch', + TIMEOUT = 'timeout', + TOKEN_EXPIRED = 'user-token-expired', + TOO_MANY_ATTEMPTS_TRY_LATER = 'too-many-requests', + UNAUTHORIZED_DOMAIN = 'unauthorized-continue-uri', + UNSUPPORTED_PERSISTENCE = 'unsupported-persistence-type', + UNSUPPORTED_TENANT_OPERATION = 'unsupported-tenant-operation', + USER_CANCELLED = 'user-cancelled', + USER_DELETED = 'user-not-found', + USER_DISABLED = 'user-disabled', + USER_MISMATCH = 'user-mismatch', + USER_SIGNED_OUT = 'user-signed-out', + WEAK_PASSWORD = 'weak-password', + WEB_STORAGE_UNSUPPORTED = 'web-storage-unsupported' +} + +const ERRORS: ErrorMap = { + [AuthErrorCode.ADMIN_ONLY_OPERATION]: + 'This operation is restricted to administrators only.', + [AuthErrorCode.ARGUMENT_ERROR]: '', + [AuthErrorCode.APP_NOT_AUTHORIZED]: + "This app, identified by the domain where it's hosted, is not " + + 'authorized to use Firebase Authentication with the provided API key. ' + + 'Review your key configuration in the Google API console.', + [AuthErrorCode.APP_NOT_INSTALLED]: + 'The requested mobile application corresponding to the identifier (' + + 'Android package name or iOS bundle ID) provided is not installed on ' + + 'this device.', + [AuthErrorCode.CAPTCHA_CHECK_FAILED]: + 'The reCAPTCHA response token provided is either invalid, expired, ' + + 'already used or the domain associated with it does not match the list ' + + 'of whitelisted domains.', + [AuthErrorCode.CODE_EXPIRED]: + 'The SMS code has expired. Please re-send the verification code to try ' + + 'again.', + [AuthErrorCode.CORDOVA_NOT_READY]: 'Cordova framework is not ready.', + [AuthErrorCode.CORS_UNSUPPORTED]: 'This browser is not supported.', + [AuthErrorCode.CREDENTIAL_ALREADY_IN_USE]: + 'This credential is already associated with a different user account.', + [AuthErrorCode.CREDENTIAL_MISMATCH]: + 'The custom token corresponds to a different audience.', + [AuthErrorCode.CREDENTIAL_TOO_OLD_LOGIN_AGAIN]: + 'This operation is sensitive and requires recent authentication. Log in ' + + 'again before retrying this request.', + [AuthErrorCode.DYNAMIC_LINK_NOT_ACTIVATED]: + 'Please activate Dynamic Links in the Firebase Console and agree to the terms and ' + + 'conditions.', + [AuthErrorCode.EMAIL_EXISTS]: + 'The email address is already in use by another account.', + [AuthErrorCode.EXPIRED_OOB_CODE]: 'The action code has expired.', + [AuthErrorCode.EXPIRED_POPUP_REQUEST]: + 'This operation has been cancelled due to another conflicting popup being opened.', + [AuthErrorCode.INTERNAL_ERROR]: 'An internal AuthError has occurred.', + [AuthErrorCode.INVALID_APP_CREDENTIAL]: + 'The phone verification request contains an invalid application verifier.' + + ' The reCAPTCHA token response is either invalid or expired.', + [AuthErrorCode.INVALID_APP_ID]: + 'The mobile app identifier is not registed for the current project.', + [AuthErrorCode.INVALID_AUTH]: + "This user's credential isn't valid for this project. This can happen " + + "if the user's token has been tampered with, or if the user isn't for " + + 'the project associated with this API key.', + [AuthErrorCode.INVALID_AUTH_EVENT]: 'An internal AuthError has occurred.', + [AuthErrorCode.INVALID_CODE]: + 'The SMS verification code used to create the phone auth credential is ' + + 'invalid. Please resend the verification code sms and be sure use the ' + + 'verification code provided by the user.', + [AuthErrorCode.INVALID_CONTINUE_URI]: + 'The continue URL provided in the request is invalid.', + [AuthErrorCode.INVALID_CORDOVA_CONFIGURATION]: + 'The following Cordova plugins must be installed to enable OAuth sign-in: ' + + 'cordova-plugin-buildinfo, cordova-universal-links-plugin, ' + + 'cordova-plugin-browsertab, cordova-plugin-inappbrowser and ' + + 'cordova-plugin-customurlscheme.', + [AuthErrorCode.INVALID_CUSTOM_TOKEN]: + 'The custom token format is incorrect. Please check the documentation.', + [AuthErrorCode.INVALID_DYNAMIC_LINK_DOMAIN]: + 'The provided dynamic link domain is not configured or authorized for the current project.', + [AuthErrorCode.INVALID_EMAIL]: 'The email address is badly formatted.', + [AuthErrorCode.INVALID_API_KEY]: + 'Your API key is invalid, please check you have copied it correctly.', + [AuthErrorCode.INVALID_CERT_HASH]: + 'The SHA-1 certificate hash provided is invalid.', + [AuthErrorCode.INVALID_IDP_RESPONSE]: + 'The supplied auth credential is malformed or has expired.', + [AuthErrorCode.INVALID_MESSAGE_PAYLOAD]: + 'The email template corresponding to this action contains invalid characters in its message. ' + + 'Please fix by going to the Auth email templates section in the Firebase Console.', + [AuthErrorCode.INVALID_OAUTH_PROVIDER]: + 'EmailAuthProvider is not supported for this operation. This operation ' + + 'only supports OAuth providers.', + [AuthErrorCode.INVALID_OAUTH_CLIENT_ID]: + 'The OAuth client ID provided is either invalid or does not match the ' + + 'specified API key.', + [AuthErrorCode.INVALID_ORIGIN]: + 'This domain is not authorized for OAuth operations for your Firebase ' + + 'project. Edit the list of authorized domains from the Firebase console.', + [AuthErrorCode.INVALID_OOB_CODE]: + 'The action code is invalid. This can happen if the code is malformed, ' + + 'expired, or has already been used.', + [AuthErrorCode.INVALID_PASSWORD]: + 'The password is invalid or the user does not have a password.', + [AuthErrorCode.INVALID_PERSISTENCE]: + 'The specified persistence type is invalid. It can only be local, session or none.', + [AuthErrorCode.INVALID_PHONE_NUMBER]: + 'The format of the phone number provided is incorrect. Please enter the ' + + 'phone number in a format that can be parsed into E.164 format. E.164 ' + + 'phone numbers are written in the format [+][country code][subscriber ' + + 'number including area code].', + [AuthErrorCode.INVALID_PROVIDER_ID]: 'The specified provider ID is invalid.', + [AuthErrorCode.INVALID_RECIPIENT_EMAIL]: + 'The email corresponding to this action failed to send as the provided ' + + 'recipient email address is invalid.', + [AuthErrorCode.INVALID_SENDER]: + 'The email template corresponding to this action contains an invalid sender email or name. ' + + 'Please fix by going to the Auth email templates section in the Firebase Console.', + [AuthErrorCode.INVALID_SESSION_INFO]: + 'The verification ID used to create the phone auth credential is invalid.', + [AuthErrorCode.INVALID_TENANT_ID]: + "The Auth instance's tenant ID is invalid.", + [AuthErrorCode.MISSING_ANDROID_PACKAGE_NAME]: + 'An Android Package Name must be provided if the Android App is required to be installed.', + [AuthErrorCode.MISSING_AUTH_DOMAIN]: + 'Be sure to include authDomain when calling firebase.initializeApp(), ' + + 'by following the instructions in the Firebase console.', + [AuthErrorCode.MISSING_APP_CREDENTIAL]: + 'The phone verification request is missing an application verifier ' + + 'assertion. A reCAPTCHA response token needs to be provided.', + [AuthErrorCode.MISSING_CODE]: + 'The phone auth credential was created with an empty SMS verification code.', + [AuthErrorCode.MISSING_CONTINUE_URI]: + 'A continue URL must be provided in the request.', + [AuthErrorCode.MISSING_IFRAME_START]: 'An internal AuthError has occurred.', + [AuthErrorCode.MISSING_IOS_BUNDLE_ID]: + 'An iOS Bundle ID must be provided if an App Store ID is provided.', + [AuthErrorCode.MISSING_OR_INVALID_NONCE]: + 'The request does not contain a valid nonce. This can occur if the ' + + 'SHA-256 hash of the provided raw nonce does not match the hashed nonce ' + + 'in the ID token payload.', + [AuthErrorCode.MISSING_PHONE_NUMBER]: + 'To send verification codes, provide a phone number for the recipient.', + [AuthErrorCode.MISSING_SESSION_INFO]: + 'The phone auth credential was created with an empty verification ID.', + [AuthErrorCode.MODULE_DESTROYED]: + 'This instance of FirebaseApp has been deleted.', + [AuthErrorCode.MFA_REQUIRED]: + 'Proof of ownership of a second factor is required to complete sign-in.', + [AuthErrorCode.NEED_CONFIRMATION]: + 'An account already exists with the same email address but different ' + + 'sign-in credentials. Sign in using a provider associated with this ' + + 'email address.', + [AuthErrorCode.NETWORK_REQUEST_FAILED]: + 'A network AuthError (such as timeout, interrupted connection or unreachable host) has occurred.', + [AuthErrorCode.NO_AUTH_EVENT]: 'An internal AuthError has occurred.', + [AuthErrorCode.NO_SUCH_PROVIDER]: + 'User was not linked to an account with the given provider.', + [AuthErrorCode.NULL_USER]: + 'A null user object was provided as the argument for an operation which ' + + 'requires a non-null user object.', + [AuthErrorCode.OPERATION_NOT_ALLOWED]: + 'The given sign-in provider is disabled for this Firebase project. ' + + 'Enable it in the Firebase console, under the sign-in method tab of the ' + + 'Auth section.', + [AuthErrorCode.OPERATION_NOT_SUPPORTED]: + 'This operation is not supported in the environment this application is ' + + 'running on. "location.protocol" must be http, https or chrome-extension' + + ' and web storage must be enabled.', + [AuthErrorCode.POPUP_BLOCKED]: + 'Unable to establish a connection with the popup. It may have been blocked by the browser.', + [AuthErrorCode.POPUP_CLOSED_BY_USER]: + 'The popup has been closed by the user before finalizing the operation.', + [AuthErrorCode.PROVIDER_ALREADY_LINKED]: + 'User can only be linked to one identity for the given provider.', + [AuthErrorCode.QUOTA_EXCEEDED]: + "The project's quota for this operation has been exceeded.", + [AuthErrorCode.REDIRECT_CANCELLED_BY_USER]: + 'The redirect operation has been cancelled by the user before finalizing.', + [AuthErrorCode.REDIRECT_OPERATION_PENDING]: + 'A redirect sign-in operation is already pending.', + [AuthErrorCode.REJECTED_CREDENTIAL]: + 'The request contains malformed or mismatching credentials.', + [AuthErrorCode.TENANT_ID_MISMATCH]: + "The provided tenant ID does not match the Auth instance's tenant ID", + [AuthErrorCode.TIMEOUT]: 'The operation has timed out.', + [AuthErrorCode.TOKEN_EXPIRED]: + "The user's credential is no longer valid. The user must sign in again.", + [AuthErrorCode.TOO_MANY_ATTEMPTS_TRY_LATER]: + 'We have blocked all requests from this device due to unusual activity. ' + + 'Try again later.', + [AuthErrorCode.UNAUTHORIZED_DOMAIN]: + 'The domain of the continue URL is not whitelisted. Please whitelist ' + + 'the domain in the Firebase console.', + [AuthErrorCode.UNSUPPORTED_PERSISTENCE]: + 'The current environment does not support the specified persistence type.', + [AuthErrorCode.UNSUPPORTED_TENANT_OPERATION]: + 'This operation is not supported in a multi-tenant context.', + [AuthErrorCode.USER_CANCELLED]: + 'The user did not grant your application the permissions it requested.', + [AuthErrorCode.USER_DELETED]: + 'There is no user record corresponding to this identifier. The user may ' + + 'have been deleted.', + [AuthErrorCode.USER_DISABLED]: + 'The user account has been disabled by an administrator.', + [AuthErrorCode.USER_MISMATCH]: + 'The supplied credentials do not correspond to the previously signed in user.', + [AuthErrorCode.USER_SIGNED_OUT]: '', + [AuthErrorCode.WEAK_PASSWORD]: + 'The password must be 6 characters long or more.', + [AuthErrorCode.WEB_STORAGE_UNSUPPORTED]: + 'This browser is not supported or 3rd party cookies and data may be disabled.' +}; + +type AuthErrorParams = { + [key in AuthErrorCode]: { + appName: AppName; + serverResponse?: object; + user?: User; + }; +}; + +export const AUTH_ERROR_FACTORY = new ErrorFactory< + AuthErrorCode, + AuthErrorParams +>('auth', 'Firebase', ERRORS); diff --git a/packages-exp/auth-exp/src/core/persistence/browser.test.ts b/packages-exp/auth-exp/src/core/persistence/browser.test.ts new file mode 100644 index 00000000000..199be82b663 --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/browser.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2019 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 } from 'chai'; +import * as sinon from 'sinon'; + +import { testUser } from '../../../test/mock_auth'; +import { PersistedBlob, Persistence, PersistenceType } from './'; +import { browserLocalPersistence, browserSessionPersistence } from './browser'; + +describe('core/persistence/browser', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + afterEach(() => sinon.restore()); + + describe('browserLocalPersistence', () => { + const persistence: Persistence = browserLocalPersistence as Persistence; + + it('should work with persistence type', async () => { + const key = 'my-super-special-persistence-type'; + const value = PersistenceType.LOCAL; + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value); + expect(await persistence.get(key)).to.be.eq(value); + expect(await persistence.get('other-key')).to.be.null; + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); + + it('should return persistedblob from user', async () => { + const key = 'my-super-special-user'; + const value = testUser({}, 'some-uid'); + + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value.toPlainObject()); + const out = await persistence.get(key); + expect(out!['uid']).to.eql(value.uid); + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); + + describe('#isAvailable', () => { + it('should emit false if localStorage setItem throws', async () => { + sinon.stub(localStorage, 'setItem').throws(new Error('nope')); + expect(await persistence.isAvailable()).to.be.false; + }); + + it('should emit false if localStorage removeItem throws', async () => { + sinon.stub(localStorage, 'removeItem').throws(new Error('nope')); + expect(await persistence.isAvailable()).to.be.false; + }); + + it('should emit true if everything works properly', async () => { + expect(await persistence.isAvailable()).to.be.true; + }); + }); + }); + + describe('browserSessionPersistence', () => { + const persistence = browserSessionPersistence as Persistence; + + it('should work with persistence type', async () => { + const key = 'my-super-special-persistence-type'; + const value = PersistenceType.SESSION; + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value); + expect(await persistence.get(key)).to.be.eq(value); + expect(await persistence.get('other-key')).to.be.null; + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); + + it('should emit blobified persisted user', async () => { + const key = 'my-super-special-user'; + const value = testUser({}, 'some-uid'); + + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value.toPlainObject()); + const out = await persistence.get(key); + expect(out!['uid']).to.eql(value.uid); + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); + + describe('#isAvailable', () => { + it('should emit false if sessionStorage setItem throws', async () => { + sinon.stub(sessionStorage, 'setItem').throws(new Error('nope')); + expect(await persistence.isAvailable()).to.be.false; + }); + + it('should emit false if sessionStorage removeItem throws', async () => { + sinon.stub(sessionStorage, 'removeItem').throws(new Error('nope')); + expect(await persistence.isAvailable()).to.be.false; + }); + + it('should emit true if everything works properly', async () => { + expect(await persistence.isAvailable()).to.be.true; + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/persistence/browser.ts b/packages-exp/auth-exp/src/core/persistence/browser.ts new file mode 100644 index 00000000000..bf40725083b --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/browser.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2019 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 * as externs from '@firebase/auth-types-exp'; + +import { + Persistence, + PersistenceType, + PersistenceValue, + STORAGE_AVAILABLE_KEY +} from './'; + +class BrowserPersistence implements Persistence { + type: PersistenceType = PersistenceType.LOCAL; + + constructor(private readonly storage: Storage) {} + + async isAvailable(): Promise { + try { + if (!this.storage) { + return false; + } + this.storage.setItem(STORAGE_AVAILABLE_KEY, '1'); + this.storage.removeItem(STORAGE_AVAILABLE_KEY); + return true; + } catch { + return false; + } + } + + async set(key: string, value: PersistenceValue): Promise { + this.storage.setItem(key, JSON.stringify(value)); + } + + async get(key: string): Promise { + const json = this.storage.getItem(key); + return json ? JSON.parse(json) : null; + } + + async remove(key: string): Promise { + this.storage.removeItem(key); + } +} + +export const browserLocalPersistence: externs.Persistence = new BrowserPersistence( + localStorage +); +export const browserSessionPersistence: externs.Persistence = new BrowserPersistence( + sessionStorage +); diff --git a/packages-exp/auth-exp/src/core/persistence/in_memory.test.ts b/packages-exp/auth-exp/src/core/persistence/in_memory.test.ts new file mode 100644 index 00000000000..fb00af5596c --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/in_memory.test.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2019 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 } from 'chai'; + +import { testUser } from '../../../test/mock_auth'; +import { Persistence, PersistenceType } from './'; +import { inMemoryPersistence } from './in_memory'; + +const persistence = inMemoryPersistence as Persistence; + +describe('core/persistence/in_memory', () => { + it('should work with persistence type', async () => { + const key = 'my-super-special-persistence-type'; + const value = PersistenceType.LOCAL; + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value); + expect(await persistence.get(key)).to.be.eq(value); + expect(await persistence.get('other-key')).to.be.null; + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); + + it('should work with user', async () => { + const key = 'my-super-special-user'; + const value = testUser({}, 'uid'); + + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value.toPlainObject()); + expect(await persistence.get(key)).to.eql(value.toPlainObject()); + expect(await persistence.get('other-key')).to.be.null; + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); + + it('isAvailable returns true', async () => { + expect(await persistence.isAvailable()).to.be.true; + }); +}); diff --git a/packages-exp/auth-exp/src/core/persistence/in_memory.ts b/packages-exp/auth-exp/src/core/persistence/in_memory.ts new file mode 100644 index 00000000000..28235a3aa2f --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/in_memory.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2019 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 * as externs from '@firebase/auth-types-exp'; + +import { Persistence, PersistenceType, PersistenceValue } from '../persistence'; + +export class InMemoryPersistence implements Persistence { + type: PersistenceType = PersistenceType.NONE; + storage: { + [key: string]: PersistenceValue; + } = {}; + + async isAvailable(): Promise { + return true; + } + + async set(key: string, value: PersistenceValue): Promise { + this.storage[key] = value; + } + + async get(key: string): Promise { + const value = this.storage[key]; + return value === undefined ? null : (value as T); + } + + async remove(key: string): Promise { + delete this.storage[key]; + } +} + +export const inMemoryPersistence: externs.Persistence = new InMemoryPersistence(); diff --git a/packages-exp/auth-exp/src/core/persistence/index.ts b/packages-exp/auth-exp/src/core/persistence/index.ts new file mode 100644 index 00000000000..938f2371a17 --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/index.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2019 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 enum PersistenceType { + SESSION = 'SESSION', + LOCAL = 'LOCAL', + NONE = 'NONE' +} + +export interface PersistedBlob { + [key: string]: unknown; +} + +export interface Instantiator { + (blob: PersistedBlob): T; +} + +export type PersistenceValue = PersistedBlob | string; + +export const STORAGE_AVAILABLE_KEY = '__sak'; + +export interface Persistence { + type: PersistenceType; + isAvailable(): Promise; + set(key: string, value: PersistenceValue): Promise; + get(key: string): Promise; + remove(key: string): Promise; +} diff --git a/packages-exp/auth-exp/src/core/persistence/indexed_db.test.ts b/packages-exp/auth-exp/src/core/persistence/indexed_db.test.ts new file mode 100644 index 00000000000..e960f867b01 --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/indexed_db.test.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2019 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 } from 'chai'; +import * as sinon from 'sinon'; + +import { testUser } from '../../../test/mock_auth'; +import { Persistence, PersistenceType } from './'; +import { indexedDBLocalPersistence } from './indexed_db'; + +const persistence = indexedDBLocalPersistence as Persistence; + +describe('core/persistence/indexed_db', () => { + afterEach(sinon.restore); + + it('should work with persistence type', async () => { + const key = 'my-super-special-persistence-type'; + const value = PersistenceType.LOCAL; + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value); + expect(await persistence.get(key)).to.be.eq(value); + expect(await persistence.get('other-key')).to.be.null; + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); + + it('should return blobified user value', async () => { + const key = 'my-super-special-user'; + const value = testUser({}, 'some-uid'); + + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value.toPlainObject()); + const out = await persistence.get(key); + expect(out).to.eql(value.toPlainObject()); + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); + + describe('#isAvaliable', () => { + it('should return true if db is available', async () => { + expect(await persistence.isAvailable()).to.be.true; + }); + + it('should return false if db creation errors', async () => { + sinon.stub(indexedDB, 'open').returns({ + addEventListener(evt: string, cb: () => void) { + if (evt === 'error') { + cb(); + } + }, + error: new DOMException('yes there was an error') + } as any); + + expect(await persistence.isAvailable()).to.be.false; + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/persistence/indexed_db.ts b/packages-exp/auth-exp/src/core/persistence/indexed_db.ts new file mode 100644 index 00000000000..cf9ae0c89f4 --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/indexed_db.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2019 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 * as externs from '@firebase/auth-types-exp'; + +import { + PersistedBlob, + Persistence, + PersistenceType, + PersistenceValue, + STORAGE_AVAILABLE_KEY +} from './'; + +export const DB_NAME = 'firebaseLocalStorageDb'; +const DB_VERSION = 1; +const DB_OBJECTSTORE_NAME = 'firebaseLocalStorage'; +const DB_DATA_KEYPATH = 'fbase_key'; + +interface DBObject { + [DB_DATA_KEYPATH]: string; + value: PersistedBlob; +} + +/** + * Promise wrapper for IDBRequest + * + * Unfortunately we can't cleanly extend Promise since promises are not callable in ES6 + */ +class DBPromise { + constructor(private readonly request: IDBRequest) {} + + toPromise(): Promise { + return new Promise((resolve, reject) => { + this.request.addEventListener('success', () => { + resolve(this.request.result); + }); + this.request.addEventListener('error', () => { + reject(this.request.error); + }); + }); + } +} + +function getObjectStore(db: IDBDatabase, isReadWrite: boolean): IDBObjectStore { + return db + .transaction([DB_OBJECTSTORE_NAME], isReadWrite ? 'readwrite' : 'readonly') + .objectStore(DB_OBJECTSTORE_NAME); +} + +function deleteDatabase(): Promise { + const request = indexedDB.deleteDatabase(DB_NAME); + return new DBPromise(request).toPromise(); +} + +function openDatabase(): Promise { + const request = indexedDB.open(DB_NAME, DB_VERSION); + return new Promise((resolve, reject) => { + request.addEventListener('error', () => { + reject(request.error); + }); + + request.addEventListener('upgradeneeded', () => { + const db = request.result; + + try { + db.createObjectStore(DB_OBJECTSTORE_NAME, { keyPath: DB_DATA_KEYPATH }); + } catch (e) { + reject(e); + } + }); + + request.addEventListener('success', async () => { + const db: IDBDatabase = request.result; + // Strange bug that occurs in Firefox when multiple tabs are opened at the + // same time. The only way to recover seems to be deleting the database + // and re-initializing it. + // https://github.com/firebase/firebase-js-sdk/issues/634 + + if (!db.objectStoreNames.contains(DB_OBJECTSTORE_NAME)) { + await deleteDatabase(); + return openDatabase(); + } else { + resolve(db); + } + }); + }); +} + +async function putObject( + db: IDBDatabase, + key: string, + value: PersistenceValue | string +): Promise { + const getRequest = getObjectStore(db, false).get(key); + const data = await new DBPromise(getRequest).toPromise(); + if (data) { + // Force an index signature on the user object + data.value = value as PersistedBlob; + const request = getObjectStore(db, true).put(data); + return new DBPromise(request).toPromise(); + } else { + const request = getObjectStore(db, true).add({ + [DB_DATA_KEYPATH]: key, + value + }); + return new DBPromise(request).toPromise(); + } +} + +async function getObject( + db: IDBDatabase, + key: string +): Promise { + const request = getObjectStore(db, false).get(key); + const data = await new DBPromise(request).toPromise(); + return data === undefined ? null : data.value; +} + +function deleteObject(db: IDBDatabase, key: string): Promise { + const request = getObjectStore(db, true).delete(key); + return new DBPromise(request).toPromise(); +} + +class IndexedDBLocalPersistence implements Persistence { + type: PersistenceType = PersistenceType.LOCAL; + db?: IDBDatabase; + + private async initialize(): Promise { + if (this.db) { + return this.db; + } + this.db = await openDatabase(); + return this.db; + } + + async isAvailable(): Promise { + try { + if (!indexedDB) { + return false; + } + const db = await openDatabase(); + await putObject(db, STORAGE_AVAILABLE_KEY, '1'); + await deleteObject(db, STORAGE_AVAILABLE_KEY); + return true; + } catch {} + return false; + } + + async set(key: string, value: PersistenceValue): Promise { + const db = await this.initialize(); + return putObject(db, key, value); + } + + async get(key: string): Promise { + const db = await this.initialize(); + const obj = await getObject(db, key); + return obj as T; + } + + async remove(key: string): Promise { + const db = await this.initialize(); + return deleteObject(db, key); + } +} + +export const indexedDBLocalPersistence: externs.Persistence = new IndexedDBLocalPersistence(); diff --git a/packages-exp/auth-exp/src/core/persistence/persistence_user_manager.test.ts b/packages-exp/auth-exp/src/core/persistence/persistence_user_manager.test.ts new file mode 100644 index 00000000000..6bf456df4bf --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/persistence_user_manager.test.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2019 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 * as chai from 'chai'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; + +import { testAuth, testUser } from '../../../test/mock_auth'; +import { Auth } from '../../model/auth'; +import { UserImpl } from '../user/user_impl'; +import { Persistence, PersistenceType } from './'; +import { inMemoryPersistence } from './in_memory'; +import { PersistenceUserManager } from './persistence_user_manager'; + +chai.use(sinonChai); + +function makePersistence( + type = PersistenceType.NONE +): { + persistence: Persistence; + stub: sinon.SinonStubbedInstance; +} { + const persistence: Persistence = { + type, + isAvailable: () => Promise.resolve(true), + set: async () => {}, + get() { + return Promise.resolve(null); + }, + remove: async () => {} + }; + + const stub = sinon.stub(persistence); + return { persistence, stub }; +} + +describe('core/persistence/persistence_user_manager', () => { + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + }); + + describe('.create', () => { + it('defaults to inMemory if no list provided', async () => { + const manager = await PersistenceUserManager.create(auth, []); + expect(manager.persistence).to.eq(inMemoryPersistence); + }); + + it('searches in order for a user', async () => { + const a = makePersistence(); + const b = makePersistence(); + const c = makePersistence(); + const search = [a.persistence, b.persistence, c.persistence]; + b.stub.get.returns(Promise.resolve(testUser({}, 'uid').toPlainObject())); + + const out = await PersistenceUserManager.create(auth, search); + expect(out.persistence).to.eq(b.persistence); + expect(a.stub.get).to.have.been.calledOnce; + expect(b.stub.get).to.have.been.calledOnce; + expect(c.stub.get).not.to.have.been.called; + }); + + it('uses default user key if none provided', async () => { + const { stub, persistence } = makePersistence(); + await PersistenceUserManager.create(auth, [persistence]); + expect(stub.get).to.have.been.calledWith( + 'firebase:authUser:test-api-key:test-app' + ); + }); + + it('uses user key if provided', async () => { + const { stub, persistence } = makePersistence(); + await PersistenceUserManager.create(auth, [persistence], 'redirectUser'); + expect(stub.get).to.have.been.calledWith( + 'firebase:redirectUser:test-api-key:test-app' + ); + }); + + it('returns zeroth persistence if all else fails', async () => { + const a = makePersistence(); + const b = makePersistence(); + const c = makePersistence(); + const search = [a.persistence, b.persistence, c.persistence]; + const out = await PersistenceUserManager.create(auth, search); + expect(out.persistence).to.eq(a.persistence); + expect(a.stub.get).to.have.been.calledOnce; + expect(b.stub.get).to.have.been.calledOnce; + expect(c.stub.get).to.have.been.called; + }); + }); + + describe('manager methods', () => { + let persistenceStub: sinon.SinonStubbedInstance; + let manager: PersistenceUserManager; + + beforeEach(async () => { + const { persistence, stub } = makePersistence(PersistenceType.SESSION); + persistenceStub = stub; + manager = await PersistenceUserManager.create(auth, [persistence]); + }); + + it('#setCurrentUser calls underlying persistence w/ key', async () => { + const user = testUser(auth, 'uid'); + await manager.setCurrentUser(user); + expect(persistenceStub.set).to.have.been.calledWith( + 'firebase:authUser:test-api-key:test-app', + user.toPlainObject() + ); + }); + + it('#removeCurrentUser calls underlying persistence', async () => { + await manager.removeCurrentUser(); + expect(persistenceStub.remove).to.have.been.calledWith( + 'firebase:authUser:test-api-key:test-app' + ); + }); + + it('#getCurrentUser calls with instantiator', async () => { + const rawObject = {}; + const userImplStub = sinon.stub(UserImpl, 'fromPlainObject'); + persistenceStub.get.returns(Promise.resolve(rawObject)); + + await manager.getCurrentUser(); + expect(userImplStub).to.have.been.calledWith(auth, rawObject); + + userImplStub.restore(); + }); + + it('#savePersistenceForRedirect calls through', async () => { + await manager.savePersistenceForRedirect(); + expect(persistenceStub.set).to.have.been.calledWith( + 'firebase:persistence:test-api-key:test-app', + 'SESSION' + ); + }); + + describe('#setPersistence', () => { + it('returns immediately if types match', async () => { + const { persistence: nextPersistence } = makePersistence( + PersistenceType.SESSION + ); + const spy = sinon.spy(manager, 'getCurrentUser'); + await manager.setPersistence(nextPersistence); + expect(spy).not.to.have.been.called; + spy.restore(); + }); + + it('removes current user & sets it in the new persistene', async () => { + const { + persistence: nextPersistence, + stub: nextStub + } = makePersistence(); + const user = testUser({}, 'uid'); + persistenceStub.get.returns(Promise.resolve(user.toPlainObject())); + + await manager.setPersistence(nextPersistence); + expect(persistenceStub.get).to.have.been.called; + expect(persistenceStub.remove).to.have.been.called; + expect(nextStub.set).to.have.been.calledWith( + 'firebase:authUser:test-api-key:test-app', + user.toPlainObject() + ); + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/persistence/persistence_user_manager.ts b/packages-exp/auth-exp/src/core/persistence/persistence_user_manager.ts new file mode 100644 index 00000000000..872a29809af --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/persistence_user_manager.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2019 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 { ApiKey, AppName, Auth } from '../../model/auth'; +import { User } from '../../model/user'; +import { PersistedBlob, Persistence } from '../persistence'; +import { UserImpl } from '../user/user_impl'; +import { inMemoryPersistence } from './in_memory'; + +export const _AUTH_USER_KEY_NAME = 'authUser'; +export const _PERSISTENCE_KEY_NAME = 'persistence'; +const _PERSISTENCE_NAMESPACE = 'firebase'; + +function _persistenceKeyName( + key: string, + apiKey: ApiKey, + appName: AppName +): string { + return `${_PERSISTENCE_NAMESPACE}:${key}:${apiKey}:${appName}`; +} + +export class PersistenceUserManager { + private readonly fullUserKey: string; + private readonly fullPersistenceKey: string; + private constructor( + public persistence: Persistence, + private readonly auth: Auth, + private readonly userKey: string + ) { + const { config, name } = this.auth; + this.fullUserKey = _persistenceKeyName(this.userKey, config.apiKey, name); + this.fullPersistenceKey = _persistenceKeyName( + _PERSISTENCE_KEY_NAME, + config.apiKey, + name + ); + } + + setCurrentUser(user: User): Promise { + return this.persistence.set(this.fullUserKey, user.toPlainObject()); + } + + async getCurrentUser(): Promise { + const blob = await this.persistence.get(this.fullUserKey); + return blob ? UserImpl.fromPlainObject(this.auth, blob) : null; + } + + removeCurrentUser(): Promise { + return this.persistence.remove(this.fullUserKey); + } + + savePersistenceForRedirect(): Promise { + return this.persistence.set(this.fullPersistenceKey, this.persistence.type); + } + + async setPersistence(newPersistence: Persistence): Promise { + if (this.persistence.type === newPersistence.type) { + return; + } + + const currentUser = await this.getCurrentUser(); + await this.removeCurrentUser(); + + this.persistence = newPersistence; + + if (currentUser) { + return this.setCurrentUser(currentUser); + } + } + + static async create( + auth: Auth, + persistenceHierarchy: Persistence[], + userKey = _AUTH_USER_KEY_NAME + ): Promise { + if (!persistenceHierarchy.length) { + return new PersistenceUserManager( + inMemoryPersistence as Persistence, + auth, + userKey + ); + } + + const key = _persistenceKeyName(userKey, auth.config.apiKey, auth.name); + for (const persistence of persistenceHierarchy) { + if (await persistence.get(key)) { + return new PersistenceUserManager(persistence, auth, userKey); + } + } + + // Check all the available storage options. + // TODO: Migrate from local storage to indexedDB + // TODO: Clear other forms once one is found + + // All else failed, fall back to zeroth persistence + // TODO: Modify this to support non-browser devices + return new PersistenceUserManager(persistenceHierarchy[0], auth, userKey); + } +} diff --git a/packages-exp/auth-exp/src/core/persistence/react_native.test.ts b/packages-exp/auth-exp/src/core/persistence/react_native.test.ts new file mode 100644 index 00000000000..e9f660befb6 --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/react_native.test.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2019 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 } from 'chai'; + +import { ReactNativeAsyncStorage } from '@firebase/auth-types-exp'; + +import { testUser } from '../../../test/mock_auth'; +import { PersistedBlob, PersistenceType } from './'; +import { ReactNativePersistence } from './react_native'; + +/** + * Wraps in-memory storage with the react native AsyncStorage API. + */ +class FakeAsyncStorage implements ReactNativeAsyncStorage { + storage: { + [key: string]: string; + } = {}; + + async getItem(key: string): Promise { + const value = this.storage[key]; + return value ?? null; + } + async removeItem(key: string): Promise { + delete this.storage[key]; + } + async setItem(key: string, value: string): Promise { + this.storage[key] = value; + } + clear(): void { + this.storage = {}; + } +} + +describe('core/persistence/react', () => { + const fakeAsyncStorage = new FakeAsyncStorage(); + const persistence = new ReactNativePersistence(fakeAsyncStorage); + + beforeEach(() => { + fakeAsyncStorage.clear(); + }); + + it('should work with persistence type', async () => { + const key = 'my-super-special-persistence-type'; + const value = PersistenceType.LOCAL; + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value); + expect(await persistence.get(key)).to.be.eq(value); + expect(await persistence.get('other-key')).to.be.null; + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); + + it('should return persistedblob from user', async () => { + const key = 'my-super-special-user'; + const value = testUser({}, 'some-uid'); + + expect(await persistence.get(key)).to.be.null; + await persistence.set(key, value.toPlainObject()); + const out = await persistence.get(key); + expect(out!['uid']).to.eql(value.uid); + await persistence.remove(key); + expect(await persistence.get(key)).to.be.null; + }); +}); diff --git a/packages-exp/auth-exp/src/core/persistence/react_native.ts b/packages-exp/auth-exp/src/core/persistence/react_native.ts new file mode 100644 index 00000000000..130cb81c4e6 --- /dev/null +++ b/packages-exp/auth-exp/src/core/persistence/react_native.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2019 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 { ReactNativeAsyncStorage } from '@firebase/auth-types-exp'; + +import { + Persistence, + PersistenceType, + PersistenceValue, + STORAGE_AVAILABLE_KEY +} from './'; + +/** + * Persistence class that wraps AsyncStorage imported from `react-native` or `@react-native-community/async-storage`. + */ +export class ReactNativePersistence implements Persistence { + readonly type: PersistenceType = PersistenceType.LOCAL; + + constructor(private readonly storage: ReactNativeAsyncStorage) {} + + async isAvailable(): Promise { + try { + if (!this.storage) { + return false; + } + await this.storage.setItem(STORAGE_AVAILABLE_KEY, '1'); + await this.storage.removeItem(STORAGE_AVAILABLE_KEY); + return true; + } catch { + return false; + } + } + + async set(key: string, value: PersistenceValue): Promise { + await this.storage.setItem(key, JSON.stringify(value)); + } + + async get(key: string): Promise { + const json = await this.storage.getItem(key); + return json ? JSON.parse(json) : null; + } + + async remove(key: string): Promise { + await this.storage.removeItem(key); + } +} diff --git a/packages-exp/auth-exp/src/core/providers/anonymous.test.ts b/packages-exp/auth-exp/src/core/providers/anonymous.test.ts new file mode 100644 index 00000000000..d1baa242385 --- /dev/null +++ b/packages-exp/auth-exp/src/core/providers/anonymous.test.ts @@ -0,0 +1,33 @@ +/** + * @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 { ProviderId, SignInMethod } from '@firebase/auth-types-exp'; +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { AnonymousProvider } from './anonymous'; + +use(chaiAsPromised); + +describe('core/providers/anonymous', () => { + describe('.credential', () => { + it('should return an anonymous credential', () => { + const credential = AnonymousProvider.credential(); + expect(credential.providerId).to.eq(ProviderId.ANONYMOUS); + expect(credential.signInMethod).to.eq(SignInMethod.ANONYMOUS); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/providers/anonymous.ts b/packages-exp/auth-exp/src/core/providers/anonymous.ts new file mode 100644 index 00000000000..f6a47088329 --- /dev/null +++ b/packages-exp/auth-exp/src/core/providers/anonymous.ts @@ -0,0 +1,27 @@ +/** + * @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 { AuthProvider, ProviderId } from '@firebase/auth-types-exp'; +import { AnonymousCredential } from '../credentials/anonymous'; + +export class AnonymousProvider implements AuthProvider { + providerId = ProviderId.ANONYMOUS; + + static credential(): AnonymousCredential { + return new AnonymousCredential(); + } +} diff --git a/packages-exp/auth-exp/src/core/providers/email.test.ts b/packages-exp/auth-exp/src/core/providers/email.test.ts new file mode 100644 index 00000000000..b628ceabe34 --- /dev/null +++ b/packages-exp/auth-exp/src/core/providers/email.test.ts @@ -0,0 +1,97 @@ +/** + * @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 { ProviderId, SignInMethod } from '@firebase/auth-types-exp'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { FirebaseError } from '@firebase/util'; +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { testAuth } from '../../../test/mock_auth'; +import { Auth } from '../../model/auth'; +import { EmailAuthProvider } from './email'; + +use(chaiAsPromised); + +describe('core/providers/email', () => { + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + }); + + describe('.credential', () => { + it('should return an email & password credential', () => { + const credential = EmailAuthProvider.credential( + 'some-email', + 'some-password' + ); + expect(credential.email).to.eq('some-email'); + expect(credential.password).to.eq('some-password'); + expect(credential.providerId).to.eq(ProviderId.PASSWORD); + expect(credential.signInMethod).to.eq(SignInMethod.EMAIL_PASSWORD); + }); + }); + + describe('.credentialWithLink', () => { + it('should return an email link credential', () => { + const continueUrl = 'https://www.example.com/path/to/file?a=1&b=2#c=3'; + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=signIn&apiKey=API_KEY&' + + 'continueUrl=' + + encodeURIComponent(continueUrl) + + '&languageCode=en&state=bla'; + + const credential = EmailAuthProvider.credentialWithLink( + auth, + 'some-email', + actionLink + ); + expect(credential.email).to.eq('some-email'); + expect(credential.password).to.eq('CODE'); + expect(credential.providerId).to.eq(ProviderId.PASSWORD); + expect(credential.signInMethod).to.eq(SignInMethod.EMAIL_LINK); + }); + + context('invalid email link', () => { + it('should throw an error', () => { + const actionLink = 'https://www.example.com/finishSignIn?'; + expect(() => + EmailAuthProvider.credentialWithLink(auth, 'some-email', actionLink) + ).to.throw(FirebaseError, 'Firebase: Error (auth/argument-error)'); + }); + }); + + context('mismatched tenant ID', () => { + it('should throw an error', () => { + const continueUrl = 'https://www.example.com/path/to/file?a=1&b=2#c=3'; + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=signIn&apiKey=API_KEY&' + + 'continueUrl=' + + encodeURIComponent(continueUrl) + + '&languageCode=en&tenantId=OTHER_TENANT_ID&state=bla'; + expect(() => + EmailAuthProvider.credentialWithLink(auth, 'some-email', actionLink) + ).to.throw( + FirebaseError, + "Firebase: The provided tenant ID does not match the Auth instance's tenant ID (auth/tenant-id-mismatch)." + ); + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/providers/email.ts b/packages-exp/auth-exp/src/core/providers/email.ts new file mode 100644 index 00000000000..bff95b0f1be --- /dev/null +++ b/packages-exp/auth-exp/src/core/providers/email.ts @@ -0,0 +1,67 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { Auth } from '../../model/auth'; +import { ActionCodeURL } from '../action_code_url'; +import { EmailAuthCredential } from '../credentials/email'; +import { AuthErrorCode } from '../errors'; +import { assert } from '../util/assert'; + +export class EmailAuthProvider implements externs.EmailAuthProvider { + static readonly PROVIDER_ID = externs.ProviderId.PASSWORD; + static readonly EMAIL_PASSWORD_SIGN_IN_METHOD = + externs.SignInMethod.EMAIL_PASSWORD; + static readonly EMAIL_LINK_SIGN_IN_METHOD = externs.SignInMethod.EMAIL_LINK; + readonly providerId = EmailAuthProvider.PROVIDER_ID; + + static credential( + email: string, + password: string, + signInMethod?: externs.SignInMethod + ): EmailAuthCredential { + return new EmailAuthCredential( + email, + password, + signInMethod || this.EMAIL_PASSWORD_SIGN_IN_METHOD + ); + } + + static credentialWithLink( + auth: Auth, + email: string, + emailLink: string + ): EmailAuthCredential { + const actionCodeUrl = ActionCodeURL.parseLink(auth, emailLink); + assert(actionCodeUrl, auth.name, AuthErrorCode.ARGUMENT_ERROR); + + // Check if the tenant ID in the email link matches the tenant ID on Auth + // instance. + assert( + actionCodeUrl.tenantId === (auth.tenantId || null), + auth.name, + AuthErrorCode.TENANT_ID_MISMATCH + ); + + return this.credential( + email, + actionCodeUrl.code, + EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD + ); + } +} diff --git a/packages-exp/auth-exp/src/core/providers/phone.test.ts b/packages-exp/auth-exp/src/core/providers/phone.test.ts new file mode 100644 index 00000000000..b6c4ee9579c --- /dev/null +++ b/packages-exp/auth-exp/src/core/providers/phone.test.ts @@ -0,0 +1,78 @@ +/** + * @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 } from 'chai'; +import * as sinon from 'sinon'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { Auth } from '../../model/auth'; +import { RecaptchaVerifier } from '../../platform_browser/recaptcha/recaptcha_verifier'; +import { PhoneAuthProvider } from './phone'; + +describe('core/providers/phone', () => { + let auth: Auth; + + beforeEach(async () => { + fetch.setUp(); + auth = await testAuth(); + }); + + afterEach(() => { + fetch.tearDown(); + sinon.restore(); + }); + + context('#verifyPhoneNumber', () => { + it('calls verify on the appVerifier and then calls the server', async () => { + const route = mockEndpoint(Endpoint.SEND_VERIFICATION_CODE, { + sessionInfo: 'verification-id' + }); + + const verifier = new RecaptchaVerifier( + document.createElement('div'), + {}, + auth + ); + sinon + .stub(verifier, 'verify') + .returns(Promise.resolve('verification-code')); + + const provider = new PhoneAuthProvider(auth); + const result = await provider.verifyPhoneNumber('+15105550000', verifier); + expect(result).to.eq('verification-id'); + expect(route.calls[0].request).to.eql({ + phoneNumber: '+15105550000', + recaptchaToken: 'verification-code' + }); + }); + }); + + context('.credential', () => { + it('creates a phone auth credential', () => { + const credential = PhoneAuthProvider.credential('id', 'code'); + + // Allows us to inspect the object + const blob = credential.toJSON() as Record; + + expect(blob.verificationId).to.eq('id'); + expect(blob.verificationCode).to.eq('code'); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/providers/phone.ts b/packages-exp/auth-exp/src/core/providers/phone.ts new file mode 100644 index 00000000000..2b7615cef4c --- /dev/null +++ b/packages-exp/auth-exp/src/core/providers/phone.ts @@ -0,0 +1,76 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { FirebaseError } from '@firebase/util'; +import { Auth } from '../../model/auth'; +import { initializeAuth } from '../auth/auth_impl'; +import { PhoneAuthCredential } from '../credentials/phone'; +import { _verifyPhoneNumber } from '../strategies/phone'; +import { debugFail } from '../util/assert'; +import { ApplicationVerifier } from '../../model/application_verifier'; + +export class PhoneAuthProvider implements externs.PhoneAuthProvider { + static readonly PROVIDER_ID = externs.ProviderId.PHONE; + static readonly PHONE_SIGN_IN_METHOD = externs.SignInMethod.PHONE; + + private readonly auth: Auth; + readonly providerId = PhoneAuthProvider.PROVIDER_ID; + + constructor(auth?: externs.Auth | null) { + this.auth = (auth || initializeAuth()) as Auth; + } + + verifyPhoneNumber( + phoneOptions: externs.PhoneInfoOptions | string, + applicationVerifier: externs.ApplicationVerifier + ): Promise { + return _verifyPhoneNumber( + this.auth, + phoneOptions, + applicationVerifier as ApplicationVerifier + ); + } + + static credential( + verificationId: string, + verificationCode: string + ): PhoneAuthCredential { + return new PhoneAuthCredential({ verificationId, verificationCode }); + } + + static _credentialFromResult( + userCredential: externs.UserCredential + ): externs.AuthCredential | null { + void userCredential; + return debugFail('not implemented'); + } + + static _credentialFromError( + error: FirebaseError + ): externs.AuthCredential | null { + void error; + return debugFail('not implemented'); + } + + static _credentialFromJSON(json: string | object): externs.AuthCredential { + void json; + return debugFail('not implemented'); + } +} diff --git a/packages-exp/auth-exp/src/core/strategies/action_code_settings.ts b/packages-exp/auth-exp/src/core/strategies/action_code_settings.ts new file mode 100644 index 00000000000..3725637dd93 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/action_code_settings.ts @@ -0,0 +1,41 @@ +/** + * @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 { ActionCodeSettings } from '@firebase/auth-types-exp'; + +import { GetOobCodeRequest } from '../../api/authentication/email_and_password'; + +export function setActionCodeSettingsOnRequest( + request: GetOobCodeRequest, + actionCodeSettings: ActionCodeSettings +): void { + request.continueUrl = actionCodeSettings.url; + request.dynamicLinkDomain = actionCodeSettings.dynamicLinkDomain; + request.canHandleCodeInApp = actionCodeSettings.handleCodeInApp; + + if (actionCodeSettings.iOS) { + request.iosBundleId = actionCodeSettings.iOS.bundleId; + request.iosAppStoreId = actionCodeSettings.iOS.appStoreId; + } + + if (actionCodeSettings.android) { + request.androidInstallApp = actionCodeSettings.android.installApp; + request.androidMinimumVersionCode = + actionCodeSettings.android.minimumVersion; + request.androidPackageName = actionCodeSettings.android.packageName; + } +} diff --git a/packages-exp/auth-exp/src/core/strategies/anonymous.test.ts b/packages-exp/auth-exp/src/core/strategies/anonymous.test.ts new file mode 100644 index 00000000000..512d732a362 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/anonymous.test.ts @@ -0,0 +1,96 @@ +/** + * @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 { + OperationType, + ProviderId, + SignInMethod +} from '@firebase/auth-types-exp'; +import { expect } from 'chai'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth, testUser } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { APIUserInfo } from '../../api/account_management/account'; +import { Auth } from '../../model/auth'; +import { signInAnonymously } from './anonymous'; + +describe('core/strategies/anonymous', () => { + let auth: Auth; + const serverUser: APIUserInfo = { + localId: 'local-id' + }; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + mockEndpoint(Endpoint.SIGN_UP, { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [serverUser] + }); + }); + afterEach(mockFetch.tearDown); + + describe('signInAnonymously', () => { + it('should sign in an anonymous user', async () => { + const { credential, user, operationType } = await signInAnonymously(auth); + expect(credential?.providerId).to.eq(ProviderId.ANONYMOUS); + expect(credential?.signInMethod).to.eq(SignInMethod.ANONYMOUS); + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user.uid).to.eq(serverUser.localId); + expect(user.isAnonymous).to.be.true; + }); + + context('already signed in anonymously', () => { + it('should return the current user', async () => { + const userCredential = await signInAnonymously(auth); + expect(userCredential.user.isAnonymous).to.be.true; + + const { credential, user, operationType } = await signInAnonymously( + auth + ); + expect(credential?.providerId).to.eq(ProviderId.ANONYMOUS); + expect(credential?.signInMethod).to.eq(SignInMethod.ANONYMOUS); + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user.uid).to.eq(userCredential.user.uid); + expect(user.isAnonymous).to.be.true; + }); + }); + + context('already signed in with a non-anonymous account', () => { + it('should sign in as a new user user', async () => { + const fakeUser = testUser(auth, 'other-uid'); + await auth.updateCurrentUser(fakeUser); + expect(fakeUser.isAnonymous).to.be.false; + + const { credential, user, operationType } = await signInAnonymously( + auth + ); + expect(credential?.providerId).to.eq(ProviderId.ANONYMOUS); + expect(credential?.signInMethod).to.eq(SignInMethod.ANONYMOUS); + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user.uid).to.not.eq(fakeUser.uid); + expect(user.isAnonymous).to.be.true; + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/strategies/anonymous.ts b/packages-exp/auth-exp/src/core/strategies/anonymous.ts new file mode 100644 index 00000000000..b4c47c64120 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/anonymous.ts @@ -0,0 +1,38 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; +import { Auth } from '../../model/auth'; +import { AnonymousProvider } from '../providers/anonymous'; +import { UserCredentialImpl } from '../user/user_credential_impl'; +import { signInWithCredential } from './credential'; + +export async function signInAnonymously( + externAuth: externs.Auth +): Promise { + const auth = externAuth as Auth; + const credential = AnonymousProvider.credential(); + if (auth.currentUser?.isAnonymous) { + // If an anonymous user is already signed in, no need to sign them in again. + return new UserCredentialImpl( + auth.currentUser, + credential, + externs.OperationType.SIGN_IN + ); + } + return signInWithCredential(auth, credential); +} diff --git a/packages-exp/auth-exp/src/core/strategies/credential.test.ts b/packages-exp/auth-exp/src/core/strategies/credential.test.ts new file mode 100644 index 00000000000..5d173aded57 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/credential.test.ts @@ -0,0 +1,217 @@ +/** + * @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 { + OperationType, + ProviderId, + SignInMethod +} from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { makeJWT } from '../../../test/jwt'; +import { testAuth, testUser } from '../../../test/mock_auth'; +import { MockAuthCredential } from '../../../test/mock_auth_credential'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { APIUserInfo } from '../../api/account_management/account'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { User } from '../../model/user'; +import { + _assertLinkedStatus, + linkWithCredential, + reauthenticateWithCredential, + signInWithCredential +} from './credential'; + +use(chaiAsPromised); + +describe('core/strategies/credential', () => { + const serverUser: APIUserInfo = { + localId: 'local-id', + displayName: 'display-name', + photoUrl: 'photo-url', + email: 'email', + emailVerified: true, + phoneNumber: 'phone-number', + tenantId: 'tenant-id', + createdAt: 123, + lastLoginAt: 456 + }; + + const idTokenResponse: IdTokenResponse = { + idToken: 'my-id-token', + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: serverUser.localId!, + kind: 'my-kind' + }; + + const authCredential = new MockAuthCredential( + ProviderId.FIREBASE, + SignInMethod.EMAIL_LINK + ); + + let auth: Auth; + let getAccountInfoEndpoint: mockFetch.Route; + let user: User; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + authCredential._setIdTokenResponse(idTokenResponse); + getAccountInfoEndpoint = mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [serverUser] + }); + + user = testUser(auth, 'uid', undefined, true); + }); + + afterEach(mockFetch.tearDown); + + describe('signInWithCredential', () => { + it('should return a valid user credential', async () => { + const { credential, user, operationType } = await signInWithCredential( + auth, + authCredential + ); + expect(credential!.providerId).to.eq(ProviderId.FIREBASE); + expect(credential!.signInMethod).to.eq(SignInMethod.EMAIL_LINK); + expect(user.uid).to.eq('local-id'); + expect(user.tenantId).to.eq('tenant-id'); + expect(user.displayName).to.eq('display-name'); + expect(operationType).to.eq(OperationType.SIGN_IN); + }); + + it('should update the current user', async () => { + const { user } = await signInWithCredential(auth, authCredential); + expect(auth.currentUser).to.eq(user); + }); + }); + + describe('reauthenticateWithCredential', () => { + it('should throw an error if the uid is mismatched', async () => { + authCredential._setIdTokenResponse({ + ...idTokenResponse, + idToken: makeJWT({ sub: 'not-my-uid' }) + }); + + await expect( + reauthenticateWithCredential(user, authCredential) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: The supplied credentials do not correspond to the previously signed in user. (auth/user-mismatch).' + ); + }); + + it('sould return the expected user credential', async () => { + authCredential._setIdTokenResponse({ + ...idTokenResponse, + idToken: makeJWT({ sub: 'uid' }) + }); + + const { + credential, + user: newUser, + operationType + } = await reauthenticateWithCredential(user, authCredential); + expect(operationType).to.eq(OperationType.REAUTHENTICATE); + expect(newUser).to.eq(user); + expect(credential).to.be.null; + }); + }); + + describe('linkWithCredential', () => { + it('should throw an error if the provider is already linked', async () => { + getAccountInfoEndpoint.response = { + users: [ + { + ...serverUser, + providerUserInfo: [{ providerId: ProviderId.FIREBASE }] + } + ] + }; + + await expect(linkWithCredential(user, authCredential)).to.be.rejectedWith( + FirebaseError, + 'Firebase: User can only be linked to one identity for the given provider. (auth/provider-already-linked).' + ); + }); + + it('should return a valid user credential', async () => { + const { + credential, + user: newUser, + operationType + } = await linkWithCredential(user, authCredential); + expect(operationType).to.eq(OperationType.LINK); + expect(newUser).to.eq(user); + expect(credential).to.be.null; + }); + }); + + describe('_assertLinkedStatus', () => { + it('should error with already linked if expectation is true', async () => { + getAccountInfoEndpoint.response = { + users: [ + { + ...serverUser, + providerUserInfo: [{ providerId: ProviderId.GOOGLE }] + } + ] + }; + + await expect( + _assertLinkedStatus(false, user, ProviderId.GOOGLE) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: User can only be linked to one identity for the given provider. (auth/provider-already-linked).' + ); + }); + + it('should not error if provider is not linked', async () => { + await expect(_assertLinkedStatus(false, user, ProviderId.GOOGLE)).not.to + .be.rejected; + }); + + it('should error if provider is not linked but it was expected to be', async () => { + await expect( + _assertLinkedStatus(true, user, ProviderId.GOOGLE) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: User was not linked to an account with the given provider. (auth/no-such-provider).' + ); + }); + + it('should not error if provider is linked and that is expected', async () => { + getAccountInfoEndpoint.response = { + users: [ + { + ...serverUser, + providerUserInfo: [{ providerId: ProviderId.GOOGLE }] + } + ] + }; + await expect(_assertLinkedStatus(true, user, ProviderId.GOOGLE)).not.to.be + .rejected; + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/strategies/credential.ts b/packages-exp/auth-exp/src/core/strategies/credential.ts new file mode 100644 index 00000000000..df6eb66f44a --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/credential.ts @@ -0,0 +1,148 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; +import { OperationType, UserCredential } from '@firebase/auth-types-exp'; + +import { PhoneOrOauthTokenResponse } from '../../api/authentication/mfa'; +import { SignInWithPhoneNumberResponse } from '../../api/authentication/sms'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { User } from '../../model/user'; +import { AuthCredential } from '../credentials'; +import { PhoneAuthCredential } from '../credentials/phone'; +import { AuthErrorCode } from '../errors'; +import { _parseToken } from '../user/id_token_result'; +import { _reloadWithoutSaving } from '../user/reload'; +import { UserCredentialImpl } from '../user/user_credential_impl'; +import { assert, fail } from '../util/assert'; +import { providerDataAsNames } from '../util/providers'; + +export async function signInWithCredential( + authExtern: externs.Auth, + credentialExtern: externs.AuthCredential +): Promise { + const auth = authExtern as Auth; + const credential = credentialExtern as AuthCredential; + // TODO: handle mfa by wrapping with callApiWithMfaContext + const response = await credential._getIdTokenResponse(auth); + const userCredential = await UserCredentialImpl._fromIdTokenResponse( + auth, + credential, + OperationType.SIGN_IN, + response + ); + await auth.updateCurrentUser(userCredential.user); + return userCredential; +} + +export async function linkWithCredential( + userExtern: externs.User, + credentialExtern: externs.AuthCredential +): Promise { + const user = userExtern as User; + const credential = credentialExtern as AuthCredential; + await _assertLinkedStatus(false, user, credential.providerId); + + const response = await credential._linkToIdToken( + user.auth, + await user.getIdToken() + ); + + return userCredForOperation(user, OperationType.LINK, response); +} + +export async function reauthenticateWithCredential( + userExtern: externs.User, + credentialExtern: externs.AuthCredential +): Promise { + const credential = credentialExtern as AuthCredential; + const user = userExtern as User; + + const { auth, uid } = user; + const response = await verifyTokenResponseUid( + credential._getReauthenticationResolver(auth), + uid, + auth.name + ); + + return userCredForOperation(user, OperationType.REAUTHENTICATE, response); +} + +export function _authCredentialFromTokenResponse( + response: PhoneOrOauthTokenResponse +): AuthCredential | null { + const { + temporaryProof, + phoneNumber + } = response as SignInWithPhoneNumberResponse; + if (temporaryProof && phoneNumber) { + return new PhoneAuthCredential({ temporaryProof, phoneNumber }); + } + + // TODO: Handle Oauth cases + return null; +} + +export async function _assertLinkedStatus( + expected: boolean, + user: User, + provider: externs.ProviderId +): Promise { + await _reloadWithoutSaving(user); + const providerIds = providerDataAsNames(user.providerData); + + const code = + expected === false + ? AuthErrorCode.PROVIDER_ALREADY_LINKED + : AuthErrorCode.NO_SUCH_PROVIDER; + assert(providerIds.has(provider) === expected, user.auth.name, code); +} + +async function verifyTokenResponseUid( + idTokenResolver: Promise, + uid: string, + appName: string +): Promise { + try { + const response = await idTokenResolver; + assert(response.idToken, appName, AuthErrorCode.INTERNAL_ERROR); + const parsed = _parseToken(response.idToken); + assert(parsed, appName, AuthErrorCode.INTERNAL_ERROR); + + const { sub: localId } = parsed; + assert(uid === localId, appName, AuthErrorCode.USER_MISMATCH); + + return response; + } catch (e) { + // Convert user deleted error into user mismatch + if (e?.code === `auth/${AuthErrorCode.USER_DELETED}`) { + fail(appName, AuthErrorCode.USER_MISMATCH); + } + throw e; + } +} + +async function userCredForOperation( + user: User, + opType: OperationType, + response: IdTokenResponse +): Promise { + const newCred = _authCredentialFromTokenResponse(response); + await user._updateTokensIfNecessary(response, /* reload */ true); + return new UserCredentialImpl(user, newCred, opType); +} diff --git a/packages-exp/auth-exp/src/core/strategies/custom_token.test.ts b/packages-exp/auth-exp/src/core/strategies/custom_token.test.ts new file mode 100644 index 00000000000..31410871830 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/custom_token.test.ts @@ -0,0 +1,83 @@ +/** + * @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 { OperationType } from '@firebase/auth-types-exp'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { APIUserInfo } from '../../api/account_management/account'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { signInWithCustomToken } from './custom_token'; + +use(chaiAsPromised); + +describe('core/strategies/signInWithCustomToken', () => { + const serverUser: APIUserInfo = { + localId: 'local-id', + displayName: 'display-name', + photoUrl: 'photo-url', + email: 'email', + emailVerified: true, + phoneNumber: 'phone-number', + tenantId: 'tenant-id', + createdAt: 123, + lastLoginAt: 456 + }; + + const idTokenResponse: IdTokenResponse = { + idToken: 'my-id-token', + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: serverUser.localId!, + kind: 'my-kind' + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + mockEndpoint(Endpoint.SIGN_IN_WITH_CUSTOM_TOKEN, idTokenResponse); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [serverUser] + }); + }); + afterEach(mockFetch.tearDown); + + it('should return a valid user credential', async () => { + const { credential, user, operationType } = await signInWithCustomToken( + auth, + 'look-at-me-im-a-jwt' + ); + expect(credential).to.be.null; + expect(user.uid).to.eq('local-id'); + expect(user.tenantId).to.eq('tenant-id'); + expect(user.displayName).to.eq('display-name'); + expect(operationType).to.eq(OperationType.SIGN_IN); + }); + + it('should update the current user', async () => { + const { user } = await signInWithCustomToken(auth, 'oh.no'); + expect(auth.currentUser).to.eq(user); + }); +}); diff --git a/packages-exp/auth-exp/src/core/strategies/custom_token.ts b/packages-exp/auth-exp/src/core/strategies/custom_token.ts new file mode 100644 index 00000000000..f8c55d73e15 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/custom_token.ts @@ -0,0 +1,41 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { signInWithCustomToken as getIdTokenResponse } from '../../api/authentication/custom_token'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { UserCredentialImpl } from '../user/user_credential_impl'; + +export async function signInWithCustomToken( + authExtern: externs.Auth, + customToken: string +): Promise { + const auth = authExtern as Auth; + const response: IdTokenResponse = await getIdTokenResponse(auth, { + token: customToken + }); + const cred = await UserCredentialImpl._fromIdTokenResponse( + auth, + null, + externs.OperationType.SIGN_IN, + response + ); + await auth.updateCurrentUser(cred.user); + return cred; +} diff --git a/packages-exp/auth-exp/src/core/strategies/email.test.ts b/packages-exp/auth-exp/src/core/strategies/email.test.ts new file mode 100644 index 00000000000..c660deaac4a --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/email.test.ts @@ -0,0 +1,216 @@ +/** + * @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 { restore, SinonStub, stub } from 'sinon'; +import * as sinonChai from 'sinon-chai'; + +import { ProviderId, Operation } from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth, testUser } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { ServerError } from '../../api/errors'; +import { Auth } from '../../model/auth'; +import { User } from '../../model/user'; +import * as location from '../util/location'; +import { fetchSignInMethodsForEmail, sendEmailVerification } from './email'; + +use(chaiAsPromised); +use(sinonChai); + +describe('core/strategies/fetchSignInMethodsForEmail', () => { + const email = 'foo@bar.com'; + const expectedSignInMethods = [ProviderId.PASSWORD, ProviderId.GOOGLE]; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should return the sign in methods', async () => { + const mock = mockEndpoint(Endpoint.CREATE_AUTH_URI, { + signinMethods: expectedSignInMethods + }); + const response = await fetchSignInMethodsForEmail(auth, email); + expect(response).to.eql(expectedSignInMethods); + expect(mock.calls[0].request).to.eql({ + identifier: email, + continueUri: location._getCurrentUrl() + }); + }); + + context('on non standard platforms', () => { + let locationStub: SinonStub; + + beforeEach(() => { + locationStub = stub(location, '_isHttpOrHttps'); + locationStub.callsFake(() => false); + }); + + afterEach(() => { + locationStub.restore(); + }); + + it('should use localhost for the continueUri', async () => { + const mock = mockEndpoint(Endpoint.CREATE_AUTH_URI, { + signinMethods: expectedSignInMethods + }); + const response = await fetchSignInMethodsForEmail(auth, email); + expect(response).to.eql(expectedSignInMethods); + expect(mock.calls[0].request).to.eql({ + identifier: email, + continueUri: 'http://localhost' + }); + }); + }); + + it('should surface errors', async () => { + const mock = mockEndpoint( + Endpoint.CREATE_AUTH_URI, + { + error: { + code: 400, + message: ServerError.INVALID_EMAIL + } + }, + 400 + ); + await expect(fetchSignInMethodsForEmail(auth, email)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is badly formatted. (auth/invalid-email).' + ); + expect(mock.calls.length).to.eq(1); + }); +}); + +describe('core/strategies/sendEmailVerification', () => { + const email = 'foo@bar.com'; + const idToken = 'id-token'; + let user: User; + let auth: Auth; + let idTokenStub: SinonStub; + let reloadStub: SinonStub; + + beforeEach(async () => { + auth = await testAuth(); + user = testUser(auth, 'my-user-uid', email); + mockFetch.setUp(); + idTokenStub = stub(user, 'getIdToken'); + idTokenStub.callsFake(async () => idToken); + reloadStub = stub(user, 'reload'); + }); + + afterEach(() => { + mockFetch.tearDown(); + restore(); + }); + + it('should send the email verification', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + requestType: Operation.VERIFY_EMAIL, + email + }); + + await sendEmailVerification(user); + + expect(reloadStub).to.not.have.been.called; + expect(mock.calls[0].request).to.eql({ + requestType: Operation.VERIFY_EMAIL, + idToken + }); + }); + + it('should reload the user if the API returns a different email', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + requestType: Operation.VERIFY_EMAIL, + email: 'other@email.com' + }); + + await sendEmailVerification(user); + + expect(reloadStub).to.have.been.calledOnce; + expect(mock.calls[0].request).to.eql({ + requestType: Operation.VERIFY_EMAIL, + idToken + }); + }); + + context('on iOS', () => { + it('should pass action code parameters', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + requestType: Operation.VERIFY_EMAIL, + email + }); + await sendEmailVerification(user, { + handleCodeInApp: true, + iOS: { + bundleId: 'my-bundle', + appStoreId: 'my-appstore-id' + }, + url: 'my-url', + dynamicLinkDomain: 'fdl-domain' + }); + + expect(mock.calls[0].request).to.eql({ + requestType: Operation.VERIFY_EMAIL, + idToken, + continueUrl: 'my-url', + dynamicLinkDomain: 'fdl-domain', + canHandleCodeInApp: true, + iosBundleId: 'my-bundle', + iosAppStoreId: 'my-appstore-id' + }); + }); + }); + + context('on Android', () => { + it('should pass action code parameters', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + requestType: Operation.VERIFY_EMAIL, + email + }); + await sendEmailVerification(user, { + handleCodeInApp: true, + android: { + installApp: false, + minimumVersion: 'my-version', + packageName: 'my-package' + }, + url: 'my-url', + dynamicLinkDomain: 'fdl-domain' + }); + expect(mock.calls[0].request).to.eql({ + requestType: Operation.VERIFY_EMAIL, + idToken, + continueUrl: 'my-url', + dynamicLinkDomain: 'fdl-domain', + canHandleCodeInApp: true, + androidInstallApp: false, + androidMinimumVersionCode: 'my-version', + androidPackageName: 'my-package' + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/strategies/email.ts b/packages-exp/auth-exp/src/core/strategies/email.ts new file mode 100644 index 00000000000..63a8d6f1348 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/email.ts @@ -0,0 +1,67 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { + createAuthUri, + CreateAuthUriRequest +} from '../../api/authentication/create_auth_uri'; +import * as api from '../../api/authentication/email_and_password'; +import { Auth } from '../../model/auth'; +import { User } from '../../model/user'; +import { _getCurrentUrl, _isHttpOrHttps } from '../util/location'; +import { setActionCodeSettingsOnRequest } from './action_code_settings'; + +export async function fetchSignInMethodsForEmail( + auth: externs.Auth, + email: string +): Promise { + // createAuthUri returns an error if continue URI is not http or https. + // For environments like Cordova, Chrome extensions, native frameworks, file + // systems, etc, use http://localhost as continue URL. + const continueUri = _isHttpOrHttps() ? _getCurrentUrl() : 'http://localhost'; + const request: CreateAuthUriRequest = { + identifier: email, + continueUri + }; + + const { signinMethods } = await createAuthUri(auth as Auth, request); + + return signinMethods || []; +} + +export async function sendEmailVerification( + userExtern: externs.User, + actionCodeSettings?: externs.ActionCodeSettings | null +): Promise { + const user = userExtern as User; + const idToken = await user.getIdToken(); + const request: api.VerifyEmailRequest = { + requestType: externs.Operation.VERIFY_EMAIL, + idToken + }; + if (actionCodeSettings) { + setActionCodeSettingsOnRequest(request, actionCodeSettings); + } + + const { email } = await api.sendEmailVerification(user.auth, request); + + if (email !== user.email) { + await user.reload(); + } +} diff --git a/packages-exp/auth-exp/src/core/strategies/email_and_password.test.ts b/packages-exp/auth-exp/src/core/strategies/email_and_password.test.ts new file mode 100644 index 00000000000..5d6f458fdb7 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/email_and_password.test.ts @@ -0,0 +1,439 @@ +/** + * @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 { + Operation, + OperationType, + ProviderId, + SignInMethod +} from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as sinonChai from 'sinon-chai'; +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { APIUserInfo } from '../../api/account_management/account'; +import { ServerError } from '../../api/errors'; +import { Auth } from '../../model/auth'; +import { + checkActionCode, + confirmPasswordReset, + createUserWithEmailAndPassword, + sendPasswordResetEmail, + signInWithEmailAndPassword, + verifyPasswordResetCode, + applyActionCode +} from './email_and_password'; + +use(chaiAsPromised); +use(sinonChai); + +describe('core/strategies/sendPasswordResetEmail', () => { + const email = 'foo@bar.com'; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should send a password reset email', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendPasswordResetEmail(auth, email); + expect(mock.calls[0].request).to.eql({ + requestType: Operation.PASSWORD_RESET, + email + }); + }); + + it('should surface errors', async () => { + const mock = mockEndpoint( + Endpoint.SEND_OOB_CODE, + { + error: { + code: 400, + message: ServerError.INVALID_EMAIL + } + }, + 400 + ); + await expect(sendPasswordResetEmail(auth, email)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is badly formatted. (auth/invalid-email).' + ); + expect(mock.calls.length).to.eq(1); + }); + + context('on iOS', () => { + it('should pass action code parameters', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendPasswordResetEmail(auth, email, { + handleCodeInApp: true, + iOS: { + bundleId: 'my-bundle', + appStoreId: 'my-appstore-id' + }, + url: 'my-url', + dynamicLinkDomain: 'fdl-domain' + }); + + expect(mock.calls[0].request).to.eql({ + requestType: Operation.PASSWORD_RESET, + email, + continueUrl: 'my-url', + dynamicLinkDomain: 'fdl-domain', + canHandleCodeInApp: true, + iosBundleId: 'my-bundle', + iosAppStoreId: 'my-appstore-id' + }); + }); + }); + + context('on Android', () => { + it('should pass action code parameters', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendPasswordResetEmail(auth, email, { + handleCodeInApp: true, + android: { + installApp: false, + minimumVersion: 'my-version', + packageName: 'my-package' + }, + url: 'my-url', + dynamicLinkDomain: 'fdl-domain' + }); + expect(mock.calls[0].request).to.eql({ + requestType: Operation.PASSWORD_RESET, + email, + continueUrl: 'my-url', + dynamicLinkDomain: 'fdl-domain', + canHandleCodeInApp: true, + androidInstallApp: false, + androidMinimumVersionCode: 'my-version', + androidPackageName: 'my-package' + }); + }); + }); +}); + +describe('core/strategies/confirmPasswordReset', () => { + const oobCode = 'oob-code'; + const newPassword = 'new-password'; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should confirm the password reset and not return the email', async () => { + const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { + email: 'foo@bar.com' + }); + const response = await confirmPasswordReset(auth, oobCode, newPassword); + expect(response).to.be.undefined; + expect(mock.calls[0].request).to.eql({ + oobCode, + newPassword + }); + }); + + it('should surface errors', async () => { + const mock = mockEndpoint( + Endpoint.RESET_PASSWORD, + { + error: { + code: 400, + message: ServerError.INVALID_OOB_CODE + } + }, + 400 + ); + await expect( + confirmPasswordReset(auth, oobCode, newPassword) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: The action code is invalid. This can happen if the code is malformed, expired, or has already been used. (auth/invalid-action-code).' + ); + expect(mock.calls.length).to.eq(1); + }); +}); + +describe('core/strategies/applyActionCode', () => { + const oobCode = 'oob-code'; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should apply the oob code', async () => { + const mock = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + await applyActionCode(auth, oobCode); + expect(mock.calls[0].request).to.eql({ + oobCode + }); + }); + + it('should surface errors', async () => { + const mock = mockEndpoint( + Endpoint.SET_ACCOUNT_INFO, + { + error: { + code: 400, + message: ServerError.INVALID_OOB_CODE + } + }, + 400 + ); + await expect(applyActionCode(auth, oobCode)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The action code is invalid. This can happen if the code is malformed, expired, or has already been used. (auth/invalid-action-code).' + ); + expect(mock.calls.length).to.eq(1); + }); +}); + +describe('core/strategies/checkActionCode', () => { + const oobCode = 'oob-code'; + const email = 'foo@bar.com'; + const newEmail = 'new@email.com'; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should verify the oob code', async () => { + const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { + requestType: Operation.PASSWORD_RESET, + email: 'foo@bar.com' + }); + const response = await checkActionCode(auth, oobCode); + expect(response).to.eql({ + data: { + email, + previousEmail: null + }, + operation: Operation.PASSWORD_RESET + }); + expect(mock.calls[0].request).to.eql({ + oobCode + }); + }); + + it('should return the newEmail', async () => { + const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { + requestType: Operation.PASSWORD_RESET, + email, + newEmail + }); + const response = await checkActionCode(auth, oobCode); + expect(response).to.eql({ + data: { + email, + previousEmail: newEmail + }, + operation: Operation.PASSWORD_RESET + }); + expect(mock.calls[0].request).to.eql({ + oobCode + }); + }); + + it('should expect a requestType', async () => { + const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { + email + }); + await expect(checkActionCode(auth, oobCode)).to.be.rejectedWith( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + expect(mock.calls.length).to.eq(1); + }); + + it('should surface errors', async () => { + const mock = mockEndpoint( + Endpoint.RESET_PASSWORD, + { + error: { + code: 400, + message: ServerError.INVALID_OOB_CODE + } + }, + 400 + ); + await expect(checkActionCode(auth, oobCode)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The action code is invalid. This can happen if the code is malformed, expired, or has already been used. (auth/invalid-action-code).' + ); + expect(mock.calls.length).to.eq(1); + }); +}); + +describe('core/strategies/verifyPasswordResetCode', () => { + const oobCode = 'oob-code'; + const email = 'foo@bar.com'; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should verify the oob code', async () => { + const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { + requestType: Operation.PASSWORD_RESET, + email: 'foo@bar.com', + previousEmail: null + }); + const response = await verifyPasswordResetCode(auth, oobCode); + expect(response).to.eq(email); + expect(mock.calls[0].request).to.eql({ + oobCode + }); + }); + + it('should expect a requestType', async () => { + const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { + email + }); + await expect(verifyPasswordResetCode(auth, oobCode)).to.be.rejectedWith( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + expect(mock.calls.length).to.eq(1); + }); + + it('should surface errors', async () => { + const mock = mockEndpoint( + Endpoint.RESET_PASSWORD, + { + error: { + code: 400, + message: ServerError.INVALID_OOB_CODE + } + }, + 400 + ); + await expect(verifyPasswordResetCode(auth, oobCode)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The action code is invalid. This can happen if the code is malformed, expired, or has already been used. (auth/invalid-action-code).' + ); + expect(mock.calls.length).to.eq(1); + }); +}); + +describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () => { + let auth: Auth; + const serverUser: APIUserInfo = { + localId: 'local-id' + }; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + mockEndpoint(Endpoint.SIGN_UP, { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [serverUser] + }); + }); + afterEach(mockFetch.tearDown); + + it('should sign in the user', async () => { + const { + credential, + user, + operationType + } = await createUserWithEmailAndPassword( + auth, + 'some-email', + 'some-password' + ); + expect(credential!.providerId).to.eq(ProviderId.PASSWORD); + expect(credential!.signInMethod).to.eq(SignInMethod.EMAIL_PASSWORD); + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user.uid).to.eq(serverUser.localId); + expect(user.isAnonymous).to.be.false; + }); +}); + +describe('core/strategies/email_and_password/signInWithEmailAndPassword', () => { + let auth: Auth; + const serverUser: APIUserInfo = { + localId: 'local-id' + }; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + mockEndpoint(Endpoint.SIGN_IN_WITH_PASSWORD, { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [serverUser] + }); + }); + afterEach(mockFetch.tearDown); + + it('should sign in the user', async () => { + const { + credential, + user, + operationType + } = await signInWithEmailAndPassword(auth, 'some-email', 'some-password'); + expect(credential!.providerId).to.eq(ProviderId.PASSWORD); + expect(credential!.signInMethod).to.eq(SignInMethod.EMAIL_PASSWORD); + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user.uid).to.eq(serverUser.localId); + expect(user.isAnonymous).to.be.false; + }); +}); diff --git a/packages-exp/auth-exp/src/core/strategies/email_and_password.ts b/packages-exp/auth-exp/src/core/strategies/email_and_password.ts new file mode 100644 index 00000000000..363c6257b1a --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/email_and_password.ts @@ -0,0 +1,146 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import * as account from '../../api/account_management/email_and_password'; +import * as authentication from '../../api/authentication/email_and_password'; +import { Auth } from '../../model/auth'; +import { AuthErrorCode } from '../errors'; +import { EmailAuthProvider } from '../providers/email'; +import { setActionCodeSettingsOnRequest } from './action_code_settings'; +import { signInWithCredential } from './credential'; +import { UserCredentialImpl } from '../user/user_credential_impl'; +import { signUp } from '../../api/authentication/sign_up'; +import { assert } from '../util/assert'; + +export async function sendPasswordResetEmail( + auth: externs.Auth, + email: string, + actionCodeSettings?: externs.ActionCodeSettings +): Promise { + const request: authentication.PasswordResetRequest = { + requestType: externs.Operation.PASSWORD_RESET, + email + }; + if (actionCodeSettings) { + setActionCodeSettingsOnRequest(request, actionCodeSettings); + } + + await authentication.sendPasswordResetEmail(auth as Auth, request); +} + +export async function confirmPasswordReset( + auth: externs.Auth, + oobCode: string, + newPassword: string +): Promise { + await account.resetPassword(auth as Auth, { + oobCode, + newPassword + }); + // Do not return the email. +} + +export async function applyActionCode( + auth: externs.Auth, + oobCode: string +): Promise { + await account.applyActionCode(auth as Auth, { oobCode }); +} + +export async function checkActionCode( + auth: externs.Auth, + oobCode: string +): Promise { + const response = await account.resetPassword(auth as Auth, { oobCode }); + + // Email could be empty only if the request type is EMAIL_SIGNIN or + // VERIFY_AND_CHANGE_EMAIL. + // New email should not be empty if the request type is + // VERIFY_AND_CHANGE_EMAIL. + const operation = response.requestType; + assert(operation, auth.name, AuthErrorCode.INTERNAL_ERROR); + switch (operation) { + case externs.Operation.EMAIL_SIGNIN: + break; + case externs.Operation.VERIFY_AND_CHANGE_EMAIL: + assert(response.newEmail, auth.name, AuthErrorCode.INTERNAL_ERROR); + break; + default: + assert(response.email, auth.name, AuthErrorCode.INTERNAL_ERROR); + } + + return { + data: { + email: + (response.requestType === externs.Operation.VERIFY_AND_CHANGE_EMAIL + ? response.newEmail + : response.email) || null, + previousEmail: + (response.requestType === externs.Operation.VERIFY_AND_CHANGE_EMAIL + ? response.email + : response.newEmail) || null + /* multiFactorInfo: MultiFactorInfo | null; */ + }, + operation + }; +} + +export async function verifyPasswordResetCode( + auth: externs.Auth, + code: string +): Promise { + const { data } = await checkActionCode(auth, code); + // Email should always be present since a code was sent to it + return data.email!; +} + +export async function createUserWithEmailAndPassword( + authExtern: externs.Auth, + email: string, + password: string +): Promise { + const auth = authExtern as Auth; + + const response = await signUp(auth, { + returnSecureToken: true, + email, + password + }); + + const userCredential = await UserCredentialImpl._fromIdTokenResponse( + auth, + EmailAuthProvider.credential(email, password), + externs.OperationType.SIGN_IN, + response + ); + await auth.updateCurrentUser(userCredential.user); + + return userCredential; +} + +export function signInWithEmailAndPassword( + auth: externs.Auth, + email: string, + password: string +): Promise { + return signInWithCredential( + auth, + EmailAuthProvider.credential(email, password) + ); +} diff --git a/packages-exp/auth-exp/src/core/strategies/email_link.test.ts b/packages-exp/auth-exp/src/core/strategies/email_link.test.ts new file mode 100644 index 00000000000..d16e0d5361a --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/email_link.test.ts @@ -0,0 +1,247 @@ +/** + * @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 sinonChai from 'sinon-chai'; + +import * as externs from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { ServerError } from '../../api/errors'; +import { Auth } from '../../model/auth'; +import { + isSignInWithEmailLink, + sendSignInLinkToEmail, + signInWithEmailLink +} from './email_link'; +import { + ProviderId, + SignInMethod, + OperationType +} from '@firebase/auth-types-exp'; +import { APIUserInfo } from '../../api/account_management/account'; + +use(chaiAsPromised); +use(sinonChai); + +describe('core/strategies/sendSignInLinkToEmail', () => { + const email = 'foo@bar.com'; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should send a sign in link via email', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendSignInLinkToEmail(auth, email); + expect(mock.calls[0].request).to.eql({ + requestType: externs.Operation.EMAIL_SIGNIN, + email + }); + }); + + it('should surface errors', async () => { + const mock = mockEndpoint( + Endpoint.SEND_OOB_CODE, + { + error: { + code: 400, + message: ServerError.INVALID_EMAIL + } + }, + 400 + ); + await expect(sendSignInLinkToEmail(auth, email)).to.be.rejectedWith( + FirebaseError, + 'Firebase: The email address is badly formatted. (auth/invalid-email).' + ); + expect(mock.calls.length).to.eq(1); + }); + + context('on iOS', () => { + it('should pass action code parameters', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendSignInLinkToEmail(auth, email, { + handleCodeInApp: true, + iOS: { + bundleId: 'my-bundle', + appStoreId: 'my-appstore-id' + }, + url: 'my-url', + dynamicLinkDomain: 'fdl-domain' + }); + + expect(mock.calls[0].request).to.eql({ + requestType: externs.Operation.EMAIL_SIGNIN, + email, + continueUrl: 'my-url', + dynamicLinkDomain: 'fdl-domain', + canHandleCodeInApp: true, + iosBundleId: 'my-bundle', + iosAppStoreId: 'my-appstore-id' + }); + }); + }); + + context('on Android', () => { + it('should pass action code parameters', async () => { + const mock = mockEndpoint(Endpoint.SEND_OOB_CODE, { + email + }); + await sendSignInLinkToEmail(auth, email, { + handleCodeInApp: true, + android: { + installApp: false, + minimumVersion: 'my-version', + packageName: 'my-package' + }, + url: 'my-url', + dynamicLinkDomain: 'fdl-domain' + }); + expect(mock.calls[0].request).to.eql({ + requestType: externs.Operation.EMAIL_SIGNIN, + email, + continueUrl: 'my-url', + dynamicLinkDomain: 'fdl-domain', + canHandleCodeInApp: true, + androidInstallApp: false, + androidMinimumVersionCode: 'my-version', + androidPackageName: 'my-package' + }); + }); + }); +}); + +describe('core/strategies/isSignInWithEmailLink', () => { + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + }); + + context('simple links', () => { + it('should recognize sign in links', () => { + const link = + 'https://www.example.com/action?mode=signIn&oobCode=oobCode&apiKey=API_KEY'; + expect(isSignInWithEmailLink(auth, link)).to.be.true; + }); + + it('should not recognize other email links', () => { + const link = + 'https://www.example.com/action?mode=verifyEmail&oobCode=oobCode&apiKey=API_KEY'; + expect(isSignInWithEmailLink(auth, link)).to.be.false; + }); + + it('should not recognize invalid links', () => { + const link = 'https://www.example.com/action?mode=signIn'; + expect(isSignInWithEmailLink(auth, link)).to.be.false; + }); + }); + + context('deep links', () => { + it('should recognize valid links', () => { + const deepLink = + 'https://www.example.com/action?mode=signIn&oobCode=oobCode&apiKey=API_KEY'; + const link = `https://example.app.goo.gl/?link=${encodeURIComponent( + deepLink + )}`; + expect(isSignInWithEmailLink(auth, link)).to.be.true; + }); + + it('should recognize valid links with deep_link_id', () => { + const deepLink = + 'https://www.example.com/action?mode=signIn&oobCode=oobCode&apiKey=API_KEY'; + const link = `somexampleiosurl://google/link?deep_link_id=${encodeURIComponent( + deepLink + )}`; + expect(isSignInWithEmailLink(auth, link)).to.be.true; + }); + + it('should reject other email links', () => { + const deepLink = + 'https://www.example.com/action?mode=verifyEmail&oobCode=oobCode&apiKey=API_KEY'; + const link = `https://example.app.goo.gl/?link=${encodeURIComponent( + deepLink + )}`; + expect(isSignInWithEmailLink(auth, link)).to.be.false; + }); + + it('should reject invalid links', () => { + const deepLink = 'https://www.example.com/action?mode=signIn'; + const link = `https://example.app.goo.gl/?link=${encodeURIComponent( + deepLink + )}`; + expect(isSignInWithEmailLink(auth, link)).to.be.false; + }); + }); +}); + +describe('core/strategies/email_and_password/signInWithEmailLink', () => { + let auth: Auth; + const serverUser: APIUserInfo = { + localId: 'local-id' + }; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + mockEndpoint(Endpoint.SIGN_IN_WITH_EMAIL_LINK, { + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '1234', + localId: serverUser.localId! + }); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [serverUser] + }); + }); + afterEach(mockFetch.tearDown); + + it('should sign in the user', async () => { + const continueUrl = 'https://www.example.com/path/to/file?a=1&b=2#c=3'; + const actionLink = + 'https://www.example.com/finishSignIn?' + + 'oobCode=CODE&mode=signIn&apiKey=API_KEY&' + + 'continueUrl=' + + encodeURIComponent(continueUrl) + + '&languageCode=en&state=bla'; + const { credential, user, operationType } = await signInWithEmailLink( + auth, + 'some-email', + actionLink + ); + expect(credential?.providerId).to.eq(ProviderId.PASSWORD); + expect(credential?.signInMethod).to.eq(SignInMethod.EMAIL_LINK); + expect(operationType).to.eq(OperationType.SIGN_IN); + expect(user.uid).to.eq(serverUser.localId); + expect(user.isAnonymous).to.be.false; + }); +}); diff --git a/packages-exp/auth-exp/src/core/strategies/email_link.ts b/packages-exp/auth-exp/src/core/strategies/email_link.ts new file mode 100644 index 00000000000..db4ba131c40 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/email_link.ts @@ -0,0 +1,65 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import * as api from '../../api/authentication/email_and_password'; +import { Auth } from '../../model/auth'; +import { ActionCodeURL } from '../action_code_url'; +import { EmailAuthProvider } from '../providers/email'; +import { _getCurrentUrl } from '../util/location'; +import { setActionCodeSettingsOnRequest } from './action_code_settings'; +import { signInWithCredential } from './credential'; + +export async function sendSignInLinkToEmail( + auth: externs.Auth, + email: string, + actionCodeSettings?: externs.ActionCodeSettings +): Promise { + const request: api.EmailSignInRequest = { + requestType: externs.Operation.EMAIL_SIGNIN, + email + }; + if (actionCodeSettings) { + setActionCodeSettingsOnRequest(request, actionCodeSettings); + } + + await api.sendSignInLinkToEmail(auth as Auth, request); +} + +export function isSignInWithEmailLink( + auth: externs.Auth, + emailLink: string +): boolean { + const actionCodeUrl = ActionCodeURL.parseLink(auth as Auth, emailLink); + return actionCodeUrl?.operation === externs.Operation.EMAIL_SIGNIN; +} + +export async function signInWithEmailLink( + auth: externs.Auth, + email: string, + emailLink?: string +): Promise { + return signInWithCredential( + auth, + EmailAuthProvider.credentialWithLink( + auth as Auth, + email, + emailLink || _getCurrentUrl() + ) + ); +} diff --git a/packages-exp/auth-exp/src/core/strategies/phone.test.ts b/packages-exp/auth-exp/src/core/strategies/phone.test.ts new file mode 100644 index 00000000000..4c94087ee08 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/phone.test.ts @@ -0,0 +1,345 @@ +/** + * @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 { OperationType, ProviderId } from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { makeJWT } from '../../../test/jwt'; +import { testAuth, testUser } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { ApplicationVerifier } from '../../model/application_verifier'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { User } from '../../model/user'; +import { RecaptchaVerifier } from '../../platform_browser/recaptcha/recaptcha_verifier'; +import { + _verifyPhoneNumber, + linkWithPhoneNumber, + reauthenticateWithPhoneNumber, + signInWithPhoneNumber +} from './phone'; + +use(chaiAsPromised); +use(sinonChai); + +describe('core/strategies/phone', () => { + let auth: Auth; + let verifier: ApplicationVerifier; + let sendCodeEndpoint: fetch.Route; + + beforeEach(async () => { + auth = await testAuth(); + fetch.setUp(); + + sendCodeEndpoint = mockEndpoint(Endpoint.SEND_VERIFICATION_CODE, { + sessionInfo: 'session-info' + }); + + verifier = new RecaptchaVerifier(document.createElement('div'), {}, auth); + sinon.stub(verifier, 'verify').returns(Promise.resolve('recaptcha-token')); + }); + + afterEach(() => { + fetch.tearDown(); + sinon.restore(); + }); + + describe('signInWithPhoneNumber', () => { + it('calls verify phone number', async () => { + await signInWithPhoneNumber(auth, '+15105550000', verifier); + + expect(sendCodeEndpoint.calls[0].request).to.eql({ + recaptchaToken: 'recaptcha-token', + phoneNumber: '+15105550000' + }); + }); + + context('ConfirmationResult', () => { + it('result contains verification id baked in', async () => { + const result = await signInWithPhoneNumber(auth, 'number', verifier); + expect(result.verificationId).to.eq('session-info'); + }); + + it('calling #confirm finishes the sign in flow', async () => { + const idTokenResponse: IdTokenResponse = { + idToken: 'my-id-token', + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: 'uid', + kind: 'my-kind' + }; + + // This endpoint is called from within the callback, in + // signInWithCredential + const signInEndpoint = mockEndpoint( + Endpoint.SIGN_IN_WITH_PHONE_NUMBER, + idTokenResponse + ); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ localId: 'uid' }] + }); + + const result = await signInWithPhoneNumber(auth, 'number', verifier); + const userCred = await result.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789' + }); + }); + }); + }); + + describe('linkWithPhoneNumber', () => { + let getAccountInfoEndpoint: fetch.Route; + let user: User; + + beforeEach(() => { + getAccountInfoEndpoint = mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ uid: 'uid' }] + }); + + user = testUser(auth, 'uid', 'email', true); + }); + + it('rejects if a phone provider is already linked', async () => { + getAccountInfoEndpoint.response = { + users: [ + { + uid: 'uid', + providerUserInfo: [{ providerId: ProviderId.PHONE }] + } + ] + }; + + await expect( + linkWithPhoneNumber(user, 'number', verifier) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: User can only be linked to one identity for the given provider. (auth/provider-already-linked).' + ); + }); + + it('calls verify phone number', async () => { + await linkWithPhoneNumber(user, '+15105550000', verifier); + + expect(sendCodeEndpoint.calls[0].request).to.eql({ + recaptchaToken: 'recaptcha-token', + phoneNumber: '+15105550000' + }); + }); + + context('ConfirmationResult', () => { + it('result contains verification id baked in', async () => { + const result = await linkWithPhoneNumber(user, 'number', verifier); + expect(result.verificationId).to.eq('session-info'); + }); + + it('calling #confirm finishes the sign in flow', async () => { + const idTokenResponse: IdTokenResponse = { + idToken: 'my-id-token', + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: 'uid', + kind: 'my-kind' + }; + + // This endpoint is called from within the callback, in + // signInWithCredential + const signInEndpoint = mockEndpoint( + Endpoint.SIGN_IN_WITH_PHONE_NUMBER, + idTokenResponse + ); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ localId: 'uid' }] + }); + + const initialIdToken = await user.getIdToken(); + + const result = await linkWithPhoneNumber(user, 'number', verifier); + const userCred = await result.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.LINK); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789', + idToken: initialIdToken + }); + }); + }); + }); + + describe('reauthenticateWithPhoneNumber', () => { + let user: User; + + beforeEach(() => { + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ uid: 'uid' }] + }); + + user = testUser(auth, 'uid', 'email', true); + }); + + it('calls verify phone number', async () => { + await reauthenticateWithPhoneNumber(user, '+15105550000', verifier); + + expect(sendCodeEndpoint.calls[0].request).to.eql({ + recaptchaToken: 'recaptcha-token', + phoneNumber: '+15105550000' + }); + }); + + context('ConfirmationResult', () => { + it('result contains verification id baked in', async () => { + const result = await reauthenticateWithPhoneNumber( + user, + 'number', + verifier + ); + expect(result.verificationId).to.eq('session-info'); + }); + + it('calling #confirm finishes the sign in flow', async () => { + const idTokenResponse: IdTokenResponse = { + idToken: makeJWT({ 'sub': 'uid' }), + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: 'uid', + kind: 'my-kind' + }; + + // This endpoint is called from within the callback, in + // signInWithCredential + const signInEndpoint = mockEndpoint( + Endpoint.SIGN_IN_WITH_PHONE_NUMBER, + idTokenResponse + ); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ localId: 'uid' }] + }); + + const result = await reauthenticateWithPhoneNumber( + user, + 'number', + verifier + ); + const userCred = await result.confirm('6789'); + expect(userCred.user.uid).to.eq('uid'); + expect(userCred.operationType).to.eq(OperationType.REAUTHENTICATE); + expect(signInEndpoint.calls[0].request).to.eql({ + sessionInfo: 'session-info', + code: '6789', + operation: 'REAUTH' + }); + }); + + it('rejects if the uid mismatches', async () => { + const idTokenResponse: IdTokenResponse = { + idToken: makeJWT({ 'sub': 'different-uid' }), + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: 'uid', + kind: 'my-kind' + }; + // This endpoint is called from within the callback, in + // signInWithCredential + mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, idTokenResponse); + + const result = await reauthenticateWithPhoneNumber( + user, + 'number', + verifier + ); + await expect(result.confirm('code')).to.be.rejectedWith( + FirebaseError, + 'Firebase: The supplied credentials do not correspond to the previously signed in user. (auth/user-mismatch)' + ); + }); + }); + }); + + describe('_verifyPhoneNumber', () => { + it('works with a string phone number', async () => { + await _verifyPhoneNumber(auth, 'number', verifier); + expect(sendCodeEndpoint.calls[0].request).to.eql({ + recaptchaToken: 'recaptcha-token', + phoneNumber: 'number' + }); + }); + + it('works with an options object', async () => { + await _verifyPhoneNumber( + auth, + { + phoneNumber: 'number' + }, + verifier + ); + expect(sendCodeEndpoint.calls[0].request).to.eql({ + recaptchaToken: 'recaptcha-token', + phoneNumber: 'number' + }); + }); + + it('throws if the verifier does not return a string', async () => { + (verifier.verify as sinon.SinonStub).returns(Promise.resolve(123)); + await expect( + _verifyPhoneNumber(auth, 'number', verifier) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: Error (auth/argument-error)' + ); + }); + + it('throws if the verifier type is not recaptcha', async () => { + const mutVerifier: { + -readonly [K in keyof ApplicationVerifier]: ApplicationVerifier[K]; + } = verifier; + mutVerifier.type = 'not-recaptcha-thats-for-sure'; + await expect( + _verifyPhoneNumber(auth, 'number', mutVerifier) + ).to.be.rejectedWith( + FirebaseError, + 'Firebase: Error (auth/argument-error)' + ); + }); + + it('resets the verifer after successful verification', async () => { + sinon.spy(verifier, '_reset'); + expect(await _verifyPhoneNumber(auth, 'number', verifier)).to.eq( + 'session-info' + ); + expect(verifier._reset).to.have.been.called; + }); + + it('resets the verifer after a failed verification', async () => { + sinon.spy(verifier, '_reset'); + (verifier.verify as sinon.SinonStub).returns(Promise.resolve(123)); + + await expect(_verifyPhoneNumber(auth, 'number', verifier)).to.be.rejected; + expect(verifier._reset).to.have.been.called; + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/strategies/phone.ts b/packages-exp/auth-exp/src/core/strategies/phone.ts new file mode 100644 index 00000000000..68c6ed4b060 --- /dev/null +++ b/packages-exp/auth-exp/src/core/strategies/phone.ts @@ -0,0 +1,142 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { sendPhoneVerificationCode } from '../../api/authentication/sms'; +import { ApplicationVerifier } from '../../model/application_verifier'; +import { Auth } from '../../model/auth'; +import { User } from '../../model/user'; +import { RECAPTCHA_VERIFIER_TYPE } from '../../platform_browser/recaptcha/recaptcha_verifier'; +import { PhoneAuthCredential } from '../credentials/phone'; +import { AuthErrorCode } from '../errors'; +import { assert } from '../util/assert'; +import { + _assertLinkedStatus, + linkWithCredential, + reauthenticateWithCredential, + signInWithCredential +} from './credential'; + +interface OnConfirmationCallback { + (credential: PhoneAuthCredential): Promise; +} + +class ConfirmationResult implements externs.ConfirmationResult { + constructor( + readonly verificationId: string, + private readonly onConfirmation: OnConfirmationCallback + ) {} + + confirm(verificationCode: string): Promise { + const authCredential = new PhoneAuthCredential({ + verificationId: this.verificationId, + verificationCode + }); + return this.onConfirmation(authCredential); + } +} + +export async function signInWithPhoneNumber( + auth: externs.Auth, + phoneNumber: string, + appVerifier: externs.ApplicationVerifier +): Promise { + const verificationId = await _verifyPhoneNumber( + auth as Auth, + phoneNumber, + appVerifier as ApplicationVerifier + ); + return new ConfirmationResult(verificationId, cred => + signInWithCredential(auth, cred) + ); +} + +export async function linkWithPhoneNumber( + userExtern: externs.User, + phoneNumber: string, + appVerifier: externs.ApplicationVerifier +): Promise { + const user = userExtern as User; + await _assertLinkedStatus(false, user, externs.ProviderId.PHONE); + const verificationId = await _verifyPhoneNumber( + user.auth, + phoneNumber, + appVerifier as ApplicationVerifier + ); + return new ConfirmationResult(verificationId, cred => + linkWithCredential(user, cred) + ); +} + +export async function reauthenticateWithPhoneNumber( + userExtern: externs.User, + phoneNumber: string, + appVerifier: externs.ApplicationVerifier +): Promise { + const user = userExtern as User; + const verificationId = await _verifyPhoneNumber( + user.auth, + phoneNumber, + appVerifier as ApplicationVerifier + ); + return new ConfirmationResult(verificationId, cred => + reauthenticateWithCredential(user, cred) + ); +} + +/** + * Returns a verification ID to be used in conjunction with the SMS code that + * is sent. + */ +export async function _verifyPhoneNumber( + auth: Auth, + options: externs.PhoneInfoOptions | string, + verifier: ApplicationVerifier +): Promise { + const recaptchaToken = await verifier.verify(); + + try { + assert( + typeof recaptchaToken === 'string', + auth.name, + AuthErrorCode.ARGUMENT_ERROR + ); + assert( + verifier.type === RECAPTCHA_VERIFIER_TYPE, + auth.name, + AuthErrorCode.ARGUMENT_ERROR + ); + + let phoneNumber: string; + if (typeof options === 'string') { + phoneNumber = options; + } else { + phoneNumber = options.phoneNumber; + } + + // MFA steps should happen here, before this next block + const { sessionInfo } = await sendPhoneVerificationCode(auth, { + phoneNumber, + recaptchaToken + }); + + return sessionInfo; + } finally { + verifier._reset(); + } +} diff --git a/packages-exp/auth-exp/src/core/user/account_info.test.ts b/packages-exp/auth-exp/src/core/user/account_info.test.ts new file mode 100644 index 00000000000..ae1e989a389 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/account_info.test.ts @@ -0,0 +1,260 @@ +/** + * @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 { ProviderId, UserInfo } from '@firebase/auth-types-exp'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { TestAuth, testAuth, testUser } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { User } from '../../model/user'; +import { updateEmail, updatePassword, updateProfile } from './account_info'; + +use(chaiAsPromised); +use(sinonChai); + +const PASSWORD_PROVIDER: UserInfo = { + providerId: ProviderId.PASSWORD, + uid: 'uid', + email: 'email', + displayName: 'old-name', + phoneNumber: 'phone-number', + photoURL: 'old-url' +}; + +describe('core/user/profile', () => { + let user: User; + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + user = testUser(auth, 'uid', '', true); + fetch.setUp(); + }); + + afterEach(() => { + sinon.restore(); + fetch.tearDown(); + }); + + describe('#updateProfile', () => { + it('returns immediately if profile object is empty', async () => { + const ep = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + await updateProfile(user, {}); + expect(ep.calls).to.be.empty; + }); + + it('calls the setAccountInfo endpoint', async () => { + const ep = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + + await updateProfile(user, { + displayName: 'displayname', + photoURL: 'photo' + }); + expect(ep.calls[0].request).to.eql({ + idToken: 'access-token', + displayName: 'displayname', + photoUrl: 'photo' + }); + }); + + it('sets the fields on the user based on the response', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + displayName: 'response-name', + photoUrl: 'response-photo' + }); + + await updateProfile(user, { + displayName: 'displayname', + photoURL: 'photo' + }); + expect(user.displayName).to.eq('response-name'); + expect(user.photoURL).to.eq('response-photo'); + }); + + it('sets the fields on the password provider', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + displayName: 'response-name', + photoUrl: 'response-photo' + }); + user.providerData = [{ ...PASSWORD_PROVIDER }]; + + await updateProfile(user, { + displayName: 'displayname', + photoURL: 'photo' + }); + const provider = user.providerData[0]; + expect(provider.displayName).to.eq('response-name'); + expect(provider.photoURL).to.eq('response-photo'); + }); + }); + + describe('#updateEmail', () => { + it('calls the setAccountInfo endpoint and reloads the user', async () => { + const set = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ localId: 'new-uid-to-prove-refresh-got-called' }] + }); + + await updateEmail(user, 'hello@test.com'); + expect(set.calls[0].request).to.eql({ + idToken: 'access-token', + email: 'hello@test.com' + }); + + expect(user.uid).to.eq('new-uid-to-prove-refresh-got-called'); + }); + }); + + describe('#updatePassword', () => { + it('calls the setAccountInfo endpoint and reloads the user', async () => { + const set = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, {}); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{ localId: 'new-uid-to-prove-refresh-got-called' }] + }); + + await updatePassword(user, 'pass'); + expect(set.calls[0].request).to.eql({ + idToken: 'access-token', + password: 'pass' + }); + + expect(user.uid).to.eq('new-uid-to-prove-refresh-got-called'); + }); + }); + + describe('notifications', () => { + let idTokenChange: sinon.SinonStub; + + beforeEach(async () => { + idTokenChange = sinon.stub(); + auth.onIdTokenChanged(idTokenChange); + + // Flush token change promises which are floating + await auth.updateCurrentUser(user); + auth._isInitialized = true; + idTokenChange.resetHistory(); + }); + + describe('#updateProfile', () => { + it('triggers a token update if necessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'new-id-token', + refreshToken: 'new-refresh-token', + expiresIn: 300 + }); + + await updateProfile(user, { displayName: 'd' }); + expect(idTokenChange).to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + + it('does NOT trigger a token update if unnecessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 300 + }); + + await updateProfile(user, { displayName: 'd' }); + expect(idTokenChange).not.to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + }); + + describe('#updateEmail', () => { + beforeEach(() => { + // This is necessary because this method calls reload; we don't care about that though, + // for these tests we're looking at the change listeners + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { users: [{}] }); + }); + + it('triggers a token update if necessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'new-id-token', + refreshToken: 'new-refresh-token', + expiresIn: 300 + }); + + await updatePassword(user, 'email@test.com'); + expect(idTokenChange).to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + + it('does NOT trigger a token update if unnecessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 300 + }); + + await updateEmail(user, 'email@test.com'); + expect(idTokenChange).not.to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + }); + + describe('#updatePassword', () => { + beforeEach(() => { + // This is necessary because this method calls reload; we don't care about that though, + // for these tests we're looking at the change listeners + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { users: [{}] }); + }); + + it('triggers a token update if necessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'new-id-token', + refreshToken: 'new-refresh-token', + expiresIn: 300 + }); + + await updatePassword(user, 'pass'); + expect(idTokenChange).to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + + it('does NOT trigger a token update if unnecessary', async () => { + mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + idToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 300 + }); + + await updatePassword(user, 'pass'); + expect(idTokenChange).not.to.have.been.called; + expect(auth.persistenceLayer.lastObjectSet).to.eql( + user.toPlainObject() + ); + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/account_info.ts b/packages-exp/auth-exp/src/core/user/account_info.ts new file mode 100644 index 00000000000..73f62ceda9f --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/account_info.ts @@ -0,0 +1,96 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { + updateEmailPassword as apiUpdateEmailPassword, + UpdateEmailPasswordRequest +} from '../../api/account_management/email_and_password'; +import { updateProfile as apiUpdateProfile } from '../../api/account_management/profile'; +import { User } from '../../model/user'; +import { _reloadWithoutSaving } from './reload'; + +interface Profile { + displayName?: string | null; + photoURL?: string | null; +} + +export async function updateProfile( + externUser: externs.User, + { displayName, photoURL: photoUrl }: Profile +): Promise { + if (displayName === undefined && photoUrl === undefined) { + return; + } + + const user = externUser as User; + const idToken = await user.getIdToken(); + const profileRequest = { idToken, displayName, photoUrl }; + const response = await apiUpdateProfile(user.auth, profileRequest); + + user.displayName = response.displayName || null; + user.photoURL = response.photoUrl || null; + + // Update the password provider as well + const passwordProvider = user.providerData.find( + ({ providerId }) => providerId === externs.ProviderId.PASSWORD + ); + if (passwordProvider) { + passwordProvider.displayName = user.displayName; + passwordProvider.photoURL = user.photoURL; + } + + await user._updateTokensIfNecessary(response); +} + +export function updateEmail( + externUser: externs.User, + newEmail: string +): Promise { + const user = externUser as User; + return updateEmailOrPassword(user, newEmail, null); +} + +export function updatePassword( + externUser: externs.User, + newPassword: string +): Promise { + const user = externUser as User; + return updateEmailOrPassword(user, null, newPassword); +} + +async function updateEmailOrPassword( + user: User, + email: string | null, + password: string | null +): Promise { + const { auth } = user; + const idToken = await user.getIdToken(); + const request: UpdateEmailPasswordRequest = { idToken }; + + if (email) { + request.email = email; + } + + if (password) { + request.password = password; + } + + const response = await apiUpdateEmailPassword(auth, request); + await user._updateTokensIfNecessary(response, /* reload */ true); +} diff --git a/packages-exp/auth-exp/src/core/user/id_token_result.test.ts b/packages-exp/auth-exp/src/core/user/id_token_result.test.ts new file mode 100644 index 00000000000..48cb26c958e --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/id_token_result.test.ts @@ -0,0 +1,142 @@ +/** + * @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 { ProviderId } from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { makeJWT } from '../../../test/jwt'; +import { testAuth, testUser } from '../../../test/mock_auth'; +import { User } from '../../model/user'; +import { getIdTokenResult } from './id_token_result'; + +use(chaiAsPromised); + +const MAY_1 = new Date('May 1, 2020'); +const MAY_2 = new Date('May 2, 2020'); +const MAY_3 = new Date('May 3, 2020'); + +describe('/core/user/id_token_result', () => { + let user: User; + + beforeEach(async () => { + user = testUser(await testAuth(), 'uid'); + }); + + function setup(token: string): void { + sinon.stub(user, 'getIdToken').returns(Promise.resolve(token)); + } + + it('throws an internal error when the token is malformed', async () => { + setup('not.valid'); + await expect(getIdTokenResult(user)).to.be.rejectedWith( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + }); + + it('builds the result properly w/ timestamps', async () => { + const token = { + 'iat': (MAY_1.getTime() / 1000).toString(), + 'auth_time': (MAY_2.getTime() / 1000).toString(), + 'exp': (MAY_3.getTime() / 1000).toString() + }; + + const encodedStr = makeJWT(token); + setup(encodedStr); + const result = await getIdTokenResult(user); + expect(result).to.eql({ + claims: token, + token: encodedStr, + issuedAtTime: MAY_1.toUTCString(), + authTime: MAY_2.toUTCString(), + expirationTime: MAY_3.toUTCString(), + signInProvider: null, + signInSecondFactor: null + }); + }); + + it('sets provider and second factor if available', async () => { + const token = { + 'iat': (MAY_1.getTime() / 1000).toString(), + 'auth_time': (MAY_2.getTime() / 1000).toString(), + 'exp': (MAY_3.getTime() / 1000).toString(), + 'firebase': { + 'sign_in_provider': ProviderId.GOOGLE, + 'sign_in_second_factor': 'sure' + } + }; + + const encodedStr = makeJWT(token); + setup(encodedStr); + const result = await getIdTokenResult(user); + expect(result).to.eql({ + claims: token, + token: encodedStr, + issuedAtTime: MAY_1.toUTCString(), + authTime: MAY_2.toUTCString(), + expirationTime: MAY_3.toUTCString(), + signInProvider: ProviderId.GOOGLE, + signInSecondFactor: 'sure' + }); + }); + + it('errors if iat is missing', async () => { + const token = { + 'auth_time': (MAY_2.getTime() / 1000).toString(), + 'exp': (MAY_3.getTime() / 1000).toString() + }; + + const encodedStr = makeJWT(token); + setup(encodedStr); + await expect(getIdTokenResult(user)).to.be.rejectedWith( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + }); + + it('errors if auth_time is missing', async () => { + const token = { + 'iat': (MAY_1.getTime() / 1000).toString(), + 'exp': (MAY_3.getTime() / 1000).toString() + }; + + const encodedStr = makeJWT(token); + setup(encodedStr); + await expect(getIdTokenResult(user)).to.be.rejectedWith( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + }); + + it('errors if exp is missing', async () => { + const token = { + 'iat': (MAY_1.getTime() / 1000).toString(), + 'auth_time': (MAY_2.getTime() / 1000).toString() + }; + + const encodedStr = makeJWT(token); + setup(encodedStr); + await expect(getIdTokenResult(user)).to.be.rejectedWith( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/id_token_result.ts b/packages-exp/auth-exp/src/core/user/id_token_result.ts new file mode 100644 index 00000000000..6312178bd92 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/id_token_result.ts @@ -0,0 +1,108 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; +import { base64Decode } from '@firebase/util'; + +import { User } from '../../model/user'; +import { assert } from '../util/assert'; +import { _logError } from '../util/log'; + +export function getIdToken( + user: externs.User, + forceRefresh = false +): Promise { + return user.getIdToken(forceRefresh); +} + +export async function getIdTokenResult( + externUser: externs.User, + forceRefresh = false +): Promise { + const user = externUser as User; + const token = await user.getIdToken(forceRefresh); + const claims = _parseToken(token); + + assert( + claims && claims.exp && claims.auth_time && claims.iat, + user.auth.name + ); + const firebase = + typeof claims.firebase === 'object' ? claims.firebase : undefined; + + const signInProvider: externs.ProviderId | undefined = firebase?.[ + 'sign_in_provider' + ] as externs.ProviderId; + + return { + claims, + token, + authTime: utcTimestampToDateString( + secondsStringToMilliseconds(claims.auth_time) + ), + issuedAtTime: utcTimestampToDateString( + secondsStringToMilliseconds(claims.iat) + ), + expirationTime: utcTimestampToDateString( + secondsStringToMilliseconds(claims.exp) + ), + signInProvider: signInProvider || null, + signInSecondFactor: + (firebase?.['sign_in_second_factor'] as externs.ProviderId) || null + }; +} + +function secondsStringToMilliseconds(seconds: string): number { + return Number(seconds) * 1000; +} + +function utcTimestampToDateString(timestamp: string | number): string { + try { + const date = new Date(Number(timestamp)); + if (!isNaN(date.getTime())) { + return date.toUTCString(); + } + } catch { + // Do nothing, return null + } + + return ''; // TODO(avolkovi): is this the right fallback? +} + +export function _parseToken(token: string): externs.ParsedToken | null { + const [algorithm, payload, signature] = token.split('.'); + if ( + algorithm === undefined || + payload === undefined || + signature === undefined + ) { + _logError('JWT malformed, contained fewer than 3 sections'); + return null; + } + + try { + const decoded = base64Decode(payload); + if (!decoded) { + _logError('Failed to decode base64 JWT payload'); + return null; + } + return JSON.parse(decoded); + } catch (e) { + _logError('Caught error parsing JWT payload as JSON', e); + return null; + } +} diff --git a/packages-exp/auth-exp/src/core/user/reload.test.ts b/packages-exp/auth-exp/src/core/user/reload.test.ts new file mode 100644 index 00000000000..74f690bc6ef --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/reload.test.ts @@ -0,0 +1,168 @@ +/** + * @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 { ProviderId, UserInfo } from '@firebase/auth-types-exp'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth, TestAuth, testUser } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { + APIUserInfo, + ProviderUserInfo +} from '../../api/account_management/account'; +import { _reloadWithoutSaving, reload } from './reload'; + +use(chaiAsPromised); +use(sinonChai); + +const BASIC_USER_INFO: UserInfo = { + providerId: ProviderId.FIREBASE, + uid: 'uid', + email: 'email', + displayName: 'display-name', + phoneNumber: 'phone-number', + photoURL: 'photo-url' +}; + +const BASIC_PROVIDER_USER_INFO: ProviderUserInfo = { + providerId: ProviderId.FIREBASE, + rawId: 'uid', + email: 'email', + displayName: 'display-name', + phoneNumber: 'phone-number', + photoUrl: 'photo-url' +}; + +describe('core/user/reload', () => { + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + fetch.setUp(); + }); + + afterEach(fetch.tearDown); + + it('sets all the new properties', async () => { + const serverUser: APIUserInfo = { + localId: 'local-id', + displayName: 'display-name', + photoUrl: 'photo-url', + email: 'email', + emailVerified: true, + phoneNumber: 'phone-number', + tenantId: 'tenant-id', + createdAt: 123, + lastLoginAt: 456 + }; + + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [serverUser] + }); + + const user = testUser(auth, 'abc', '', true); + await _reloadWithoutSaving(user); + expect(user.uid).to.eq('local-id'); + expect(user.displayName).to.eq('display-name'); + expect(user.photoURL).to.eq('photo-url'); + expect(user.email).to.eq('email'); + expect(user.emailVerified).to.be.true; + expect(user.phoneNumber).to.eq('phone-number'); + expect(user.tenantId).to.eq('tenant-id'); + expect(user.metadata).to.eql({ + creationTime: '123', + lastSignInTime: '456' + }); + }); + + it('adds missing provider data', async () => { + const user = testUser(auth, 'abc', '', true); + user.providerData = [{ ...BASIC_USER_INFO }]; + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [ + { + providerUserInfo: [ + { ...BASIC_PROVIDER_USER_INFO, providerId: ProviderId.FACEBOOK } + ] + } + ] + }); + await _reloadWithoutSaving(user); + expect(user.providerData).to.eql([ + { ...BASIC_USER_INFO }, + { ...BASIC_USER_INFO, providerId: ProviderId.FACEBOOK } + ]); + }); + + it('merges provider data, using the new data for overlaps', async () => { + const user = testUser(auth, 'abc', '', true); + user.providerData = [ + { + ...BASIC_USER_INFO, + providerId: ProviderId.GITHUB, + uid: 'i-will-be-overwritten' + }, + { + ...BASIC_USER_INFO + } + ]; + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [ + { + providerUserInfo: [ + { + ...BASIC_PROVIDER_USER_INFO, + providerId: ProviderId.GITHUB, + rawId: 'new-uid' + } + ] + } + ] + }); + await _reloadWithoutSaving(user); + expect(user.providerData).to.eql([ + { ...BASIC_USER_INFO }, + { + ...BASIC_USER_INFO, + providerId: ProviderId.GITHUB, + uid: 'new-uid' + } + ]); + }); + + it('reload persists the object and notifies listeners', async () => { + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [{}] + }); + + const user = testUser(auth, 'user', '', true); + user.auth.currentUser = user; + + const cb = sinon.stub(); + user.auth.onIdTokenChanged(cb); + + await reload(user); + expect(cb).to.have.been.calledWith(user); + expect(auth.persistenceLayer.lastObjectSet).to.eql(user.toPlainObject()); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/reload.ts b/packages-exp/auth-exp/src/core/user/reload.ts new file mode 100644 index 00000000000..e0fec052988 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/reload.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2019 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 * as externs from '@firebase/auth-types-exp'; + +import { + getAccountInfo, + ProviderUserInfo +} from '../../api/account_management/account'; +import { User } from '../../model/user'; +import { assert } from '../util/assert'; + +export async function _reloadWithoutSaving(user: User): Promise { + const auth = user.auth; + const idToken = await user.getIdToken(); + const response = await getAccountInfo(auth, { idToken }); + + assert(response?.users.length, auth.name); + + const coreAccount = response.users[0]; + const newProviderData = coreAccount.providerUserInfo?.length + ? extractProviderData(coreAccount.providerUserInfo) + : []; + const updates: Partial = { + uid: coreAccount.localId, + displayName: coreAccount.displayName || null, + photoURL: coreAccount.photoUrl || null, + email: coreAccount.email || null, + emailVerified: coreAccount.emailVerified || false, + phoneNumber: coreAccount.phoneNumber || null, + tenantId: coreAccount.tenantId || null, + providerData: mergeProviderData(user.providerData, newProviderData), + metadata: { + creationTime: coreAccount.createdAt?.toString(), + lastSignInTime: coreAccount.lastLoginAt?.toString() + } + }; + + Object.assign(user, updates); +} + +export async function reload(externUser: externs.User): Promise { + const user: User = externUser as User; + await _reloadWithoutSaving(user); + + // Even though the current user hasn't changed, update + // current user will trigger a persistence update w/ the + // new info. + await user.auth._persistUserIfCurrent(user); + user.auth._notifyListenersIfCurrent(user); +} + +function mergeProviderData( + original: externs.UserInfo[], + newData: externs.UserInfo[] +): externs.UserInfo[] { + const deduped = original.filter( + o => !newData.some(n => n.providerId === o.providerId) + ); + return [...deduped, ...newData]; +} + +function extractProviderData( + providers: ProviderUserInfo[] +): externs.UserInfo[] { + return providers.map(({ providerId, ...provider }) => { + return { + uid: provider.rawId || '', + displayName: provider.displayName || null, + email: provider.email || null, + phoneNumber: provider.phoneNumber || null, + providerId: providerId as externs.ProviderId, + photoURL: provider.photoUrl || null + }; + }); +} diff --git a/packages-exp/auth-exp/src/core/user/token_manager.test.ts b/packages-exp/auth-exp/src/core/user/token_manager.test.ts new file mode 100644 index 00000000000..d0c8af9e366 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/token_manager.test.ts @@ -0,0 +1,219 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; + +import { testAuth } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { _ENDPOINT } from '../../api/authentication/token'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { StsTokenManager, TOKEN_REFRESH_BUFFER_MS } from './token_manager'; + +use(chaiAsPromised); + +describe('core/user/token_manager', () => { + let stsTokenManager: StsTokenManager; + let now: number; + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + stsTokenManager = new StsTokenManager(); + now = Date.now(); + sinon.stub(Date, 'now').returns(now); + }); + + beforeEach(fetch.setUp); + afterEach(fetch.tearDown); + afterEach(() => sinon.restore()); + + describe('#isExpired', () => { + it('is true if past expiration time', () => { + stsTokenManager.expirationTime = 1; // Ancient history + expect(stsTokenManager.isExpired).to.eq(true); + }); + + it('is true if exp is in future but within buffer', () => { + stsTokenManager.expirationTime = now + (TOKEN_REFRESH_BUFFER_MS - 10); + expect(stsTokenManager.isExpired).to.eq(true); + }); + + it('is fals if exp is far enough in future', () => { + stsTokenManager.expirationTime = now + (TOKEN_REFRESH_BUFFER_MS + 10); + expect(stsTokenManager.isExpired).to.eq(false); + }); + }); + + describe('#updateFromServerResponse', () => { + it('sets all the fields correctly', () => { + stsTokenManager.updateFromServerResponse({ + idToken: 'id-token', + refreshToken: 'refresh-token', + expiresIn: '60' // From the server this is 30s + } as IdTokenResponse); + + expect(stsTokenManager.expirationTime).to.eq(now + 60_000); + expect(stsTokenManager.accessToken).to.eq('id-token'); + expect(stsTokenManager.refreshToken).to.eq('refresh-token'); + }); + }); + + describe('#clearRefreshToken', () => { + it('sets refresh token to null', () => { + stsTokenManager.refreshToken = 'refresh-token'; + stsTokenManager.clearRefreshToken(); + expect(stsTokenManager.refreshToken).to.be.null; + }); + }); + + describe('#getToken', () => { + context('with endpoint setup', () => { + let mock: fetch.Route; + beforeEach(() => { + const { apiKey, tokenApiHost, apiScheme } = auth.config; + const endpoint = `${apiScheme}://${tokenApiHost}/${_ENDPOINT}?key=${apiKey}`; + mock = fetch.mock(endpoint, { + 'access_token': 'new-access-token', + 'refresh_token': 'new-refresh-token', + 'expires_in': '3600' + }); + }); + + it('refreshes the token if forceRefresh is true', async () => { + Object.assign(stsTokenManager, { + accessToken: 'old-access-token', + refreshToken: 'old-refresh-token', + expirationTime: now + 100_000 + }); + + const tokens = await stsTokenManager.getToken(auth, true); + expect(mock.calls[0].request).to.contain('old-refresh-token'); + expect(stsTokenManager.accessToken).to.eq('new-access-token'); + expect(stsTokenManager.refreshToken).to.eq('new-refresh-token'); + expect(stsTokenManager.expirationTime).to.eq(now + 3_600_000); + + expect(tokens).to.eql({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + wasRefreshed: true + }); + }); + + it('refreshes the token if token is expired', async () => { + Object.assign(stsTokenManager, { + accessToken: 'old-access-token', + refreshToken: 'old-refresh-token', + expirationTime: now - 1 + }); + + const tokens = await stsTokenManager.getToken(auth, false); + expect(mock.calls[0].request).to.contain('old-refresh-token'); + expect(stsTokenManager.accessToken).to.eq('new-access-token'); + expect(stsTokenManager.refreshToken).to.eq('new-refresh-token'); + expect(stsTokenManager.expirationTime).to.eq(now + 3_600_000); + + expect(tokens).to.eql({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + wasRefreshed: true + }); + }); + }); + + it('returns null if the refresh token is missing', async () => { + expect(await stsTokenManager.getToken(auth)).to.be.null; + }); + + it('throws an error if expired but refresh token is missing', async () => { + Object.assign(stsTokenManager, { + accessToken: 'old-access-token', + expirationTime: now - 1 + }); + + await expect(stsTokenManager.getToken(auth)).to.be.rejectedWith( + FirebaseError, + "Firebase: The user's credential is no longer valid. The user must sign in again. (auth/user-token-expired)" + ); + }); + + it('returns access token if not expired, not refreshing', async () => { + Object.assign(stsTokenManager, { + accessToken: 'token', + refreshToken: 'refresh', + expirationTime: now + 100_000 + }); + + const tokens = (await stsTokenManager.getToken(auth))!; + expect(tokens).to.eql({ + accessToken: 'token', + refreshToken: 'refresh', + wasRefreshed: false + }); + }); + }); + + describe('.fromPlainObject', () => { + const errorString = + 'Firebase: An internal AuthError has occurred. (auth/internal-error).'; + + it('throws if refresh token is not a string', () => { + expect(() => + StsTokenManager.fromPlainObject('app', { + refreshToken: 45, + accessToken: 't', + expirationTime: 3 + }) + ).to.throw(FirebaseError, errorString); + }); + + it('throws if access token is not a string', () => { + expect(() => + StsTokenManager.fromPlainObject('app', { + refreshToken: 't', + accessToken: 45, + expirationTime: 3 + }) + ).to.throw(FirebaseError, errorString); + }); + + it('throws if expiration time is not a number', () => { + expect(() => + StsTokenManager.fromPlainObject('app', { + refreshToken: 't', + accessToken: 't', + expirationTime: 'lol' + }) + ).to.throw(FirebaseError, errorString); + }); + + it('builds an object correctly', () => { + const manager = StsTokenManager.fromPlainObject('app', { + refreshToken: 'r', + accessToken: 'a', + expirationTime: 45 + }); + expect(manager.accessToken).to.eq('a'); + expect(manager.refreshToken).to.eq('r'); + expect(manager.expirationTime).to.eq(45); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/token_manager.ts b/packages-exp/auth-exp/src/core/user/token_manager.ts new file mode 100644 index 00000000000..34840bba934 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/token_manager.ts @@ -0,0 +1,146 @@ +/** + * @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 { requestStsToken } from '../../api/authentication/token'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { AUTH_ERROR_FACTORY, AuthErrorCode } from '../errors'; +import { PersistedBlob } from '../persistence'; +import { assert } from '../util/assert'; + +/** + * The number of milliseconds before the official expiration time of a token + * to refresh that token, to provide a buffer for RPCs to complete. + */ +export const TOKEN_REFRESH_BUFFER_MS = 30_000; + +export interface Tokens { + accessToken: string; + refreshToken: string | null; + wasRefreshed: boolean; +} + +export class StsTokenManager { + refreshToken: string | null = null; + accessToken: string | null = null; + expirationTime: number | null = null; + + get isExpired(): boolean { + return ( + !this.expirationTime || + Date.now() > this.expirationTime - TOKEN_REFRESH_BUFFER_MS + ); + } + + updateFromServerResponse({ + idToken, + refreshToken, + expiresIn: expiresInSec + }: IdTokenResponse): void { + this.updateTokensAndExpiration(idToken, refreshToken, expiresInSec); + } + + async getToken(auth: Auth, forceRefresh = false): Promise { + if (!forceRefresh && this.accessToken && !this.isExpired) { + return { + accessToken: this.accessToken, + refreshToken: this.refreshToken, + wasRefreshed: false + }; + } + + if (this.accessToken && !this.refreshToken) { + throw AUTH_ERROR_FACTORY.create(AuthErrorCode.TOKEN_EXPIRED, { + appName: auth.name + }); + } + + if (!this.refreshToken) { + return null; + } + + await this.refresh(auth, this.refreshToken); + return { + accessToken: this.accessToken!, + refreshToken: this.refreshToken, + wasRefreshed: true + }; + } + + clearRefreshToken(): void { + this.refreshToken = null; + } + + toPlainObject(): object { + return { + refreshToken: this.refreshToken, + accessToken: this.accessToken, + expirationTime: this.expirationTime + }; + } + + private async refresh(auth: Auth, oldToken: string): Promise { + const { accessToken, refreshToken, expiresIn } = await requestStsToken( + auth, + oldToken + ); + this.updateTokensAndExpiration( + accessToken || null, + refreshToken || null, + expiresIn || null + ); + } + + private updateTokensAndExpiration( + accessToken: string | null, + refreshToken: string | null, + expiresInSec: string | null + ): void { + this.refreshToken = refreshToken; + this.accessToken = accessToken; + this.expirationTime = expiresInSec + ? Date.now() + Number(expiresInSec) * 1000 + : null; + } + + static fromPlainObject( + appName: string, + object: PersistedBlob + ): StsTokenManager { + const { refreshToken, accessToken, expirationTime } = object; + + const manager = new StsTokenManager(); + if (refreshToken) { + assert(typeof refreshToken === 'string', appName); + manager.refreshToken = refreshToken; + } + if (accessToken) { + assert(typeof accessToken === 'string', appName); + manager.accessToken = accessToken; + } + if (expirationTime) { + assert(typeof expirationTime === 'number', appName); + manager.expirationTime = expirationTime; + } + return manager; + } + + // TODO: There are a few more methods in here that need implemented: + // # toPlainObject + // # fromPlainObject + // # (private) performRefresh +} diff --git a/packages-exp/auth-exp/src/core/user/unlink.test.ts b/packages-exp/auth-exp/src/core/user/unlink.test.ts new file mode 100644 index 00000000000..eeffc2f8331 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/unlink.test.ts @@ -0,0 +1,162 @@ +/** + * @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 { ProviderId } from '@firebase/auth-types-exp'; +import { FirebaseError } from '@firebase/util'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth, TestAuth, testUser } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { User } from '../../model/user'; +import { unlink } from './unlink'; + +use(chaiAsPromised); + +describe('core/user/unlink', () => { + let user: User; + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + user = testUser(auth, 'uid', '', true); + await auth.updateCurrentUser(user); + fetch.setUp(); + }); + + afterEach(() => { + fetch.tearDown(); + }); + + it('rejects if the provider is not linked', async () => { + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [ + { + uid: 'uid' + } + ] + }); + + await expect(unlink(user, ProviderId.PHONE)).to.be.rejectedWith( + FirebaseError, + 'Firebase: User was not linked to an account with the given provider. (auth/no-such-provider).' + ); + }); + + context('with properly linked account', () => { + let endpoint: fetch.Route; + beforeEach(() => { + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [ + { + uid: 'uid', + providerUserInfo: [{ providerId: ProviderId.PHONE }] + } + ] + }); + + endpoint = mockEndpoint(Endpoint.SET_ACCOUNT_INFO, { + providerUserInfo: [ + { + providerId: ProviderId.GOOGLE + } + ] + }); + }); + + it('removes the phone provider from the list and persists', async () => { + user.phoneNumber = 'number!'; + user.providerData = [ + { + providerId: ProviderId.PHONE, + displayName: '', + phoneNumber: '', + email: '', + photoURL: '', + uid: '' + }, + { + providerId: ProviderId.GOOGLE, + displayName: '', + phoneNumber: '', + email: '', + photoURL: '', + uid: '' + } + ]; + await unlink(user, ProviderId.PHONE); + expect(user.providerData).to.eql([ + { + providerId: ProviderId.GOOGLE, + displayName: '', + phoneNumber: '', + email: '', + photoURL: '', + uid: '' + } + ]); + + expect(auth.persistenceLayer.lastObjectSet).to.eql(user.toPlainObject()); + expect(user.phoneNumber).to.be.null; + }); + + it('removes non-phone provider from the list and persists', async () => { + user.providerData = [ + { + providerId: ProviderId.GOOGLE, + displayName: '', + phoneNumber: '', + email: '', + photoURL: '', + uid: '' + }, + { + providerId: ProviderId.TWITTER, + displayName: '', + phoneNumber: '', + email: '', + photoURL: '', + uid: '' + } + ]; + await unlink(user, ProviderId.TWITTER); + expect(user.providerData).to.eql([ + { + providerId: ProviderId.GOOGLE, + displayName: '', + phoneNumber: '', + email: '', + photoURL: '', + uid: '' + } + ]); + + expect(auth.persistenceLayer.lastObjectSet).to.eql(user.toPlainObject()); + }); + + it('calls the endpoint with the provider', async () => { + await unlink(user, ProviderId.PHONE); + expect(endpoint.calls[0].request).to.eql({ + idToken: await user.getIdToken(), + deleteProvider: [ProviderId.PHONE] + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/unlink.ts b/packages-exp/auth-exp/src/core/user/unlink.ts new file mode 100644 index 00000000000..d2b73e81f30 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/unlink.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2019 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 * as externs from '@firebase/auth-types-exp'; + +import { deleteLinkedAccounts } from '../../api/account_management/account'; +import { User } from '../../model/user'; +import { _assertLinkedStatus } from '../strategies/credential'; +import { providerDataAsNames } from '../util/providers'; + +export async function unlink( + userExtern: externs.User, + providerId: externs.ProviderId +): Promise { + const user = userExtern as User; + await _assertLinkedStatus(true, user, providerId); + const { providerUserInfo } = await deleteLinkedAccounts(user.auth, { + idToken: await user.getIdToken(), + deleteProvider: [providerId] + }); + + const providersLeft = providerDataAsNames(providerUserInfo || []); + + user.providerData = user.providerData.filter(pd => + providersLeft.has(pd.providerId) + ); + if (!providersLeft.has(externs.ProviderId.PHONE)) { + user.phoneNumber = null; + } + + await user.auth._persistUserIfCurrent(user); + return user; +} diff --git a/packages-exp/auth-exp/src/core/user/user_credential_impl.test.ts b/packages-exp/auth-exp/src/core/user/user_credential_impl.test.ts new file mode 100644 index 00000000000..12ee62ae61b --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/user_credential_impl.test.ts @@ -0,0 +1,108 @@ +/** + * @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 { + OperationType, + ProviderId, + SignInMethod +} from '@firebase/auth-types-exp'; + +import { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import { MockAuthCredential } from '../../../test/mock_auth_credential'; +import * as mockFetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { APIUserInfo } from '../../api/account_management/account'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { UserCredentialImpl } from './user_credential_impl'; +import { AuthCredential } from '../credentials'; + +use(chaiAsPromised); +use(sinonChai); + +describe('core/user/user_credential_impl', () => { + const serverUser: APIUserInfo = { + localId: 'local-id', + displayName: 'display-name', + photoUrl: 'photo-url', + email: 'email', + emailVerified: true, + phoneNumber: 'phone-number', + tenantId: 'tenant-id', + createdAt: 123, + lastLoginAt: 456 + }; + + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [serverUser] + }); + }); + afterEach(mockFetch.tearDown); + + describe('fromIdTokenResponse', () => { + const idTokenResponse: IdTokenResponse = { + idToken: 'my-id-token', + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: serverUser.localId!, + kind: 'my-kind' + }; + + const credential: AuthCredential = new MockAuthCredential( + ProviderId.FIREBASE, + SignInMethod.EMAIL_PASSWORD + ); + + it('should initialize a UserCredential', async () => { + const userCredential = await UserCredentialImpl._fromIdTokenResponse( + auth, + credential, + OperationType.SIGN_IN, + idTokenResponse + ); + expect(userCredential.credential).to.eq(credential); + expect(userCredential.operationType).to.eq(OperationType.SIGN_IN); + expect(userCredential.user.uid).to.eq('local-id'); + }); + + it('should not trigger callbacks', async () => { + const cb = sinon.spy(); + auth.onAuthStateChanged(cb); + await auth.updateCurrentUser(null); + cb.resetHistory(); + + await UserCredentialImpl._fromIdTokenResponse( + auth, + credential, + OperationType.SIGN_IN, + idTokenResponse + ); + expect(cb).not.to.have.been.called; + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/user_credential_impl.ts b/packages-exp/auth-exp/src/core/user/user_credential_impl.ts new file mode 100644 index 00000000000..81d3ee04c08 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/user_credential_impl.ts @@ -0,0 +1,49 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { User, UserCredential } from '../../model/user'; +import { UserImpl } from './user_impl'; +import { AuthCredential } from '../credentials'; + +export class UserCredentialImpl implements UserCredential { + constructor( + public readonly user: User, + public readonly credential: AuthCredential | null, + public readonly operationType: externs.OperationType + ) {} + + static async _fromIdTokenResponse( + auth: Auth, + credential: AuthCredential | null, + operationType: externs.OperationType, + idTokenResponse: IdTokenResponse + ): Promise { + const user = await UserImpl._fromIdTokenResponse( + auth, + idTokenResponse, + credential?.providerId === externs.ProviderId.ANONYMOUS + ); + const userCred = new UserCredentialImpl(user, credential, operationType); + // TODO: handle additional user info + // updateAdditionalUserInfoFromIdTokenResponse(userCred, idTokenResponse); + return userCred; + } +} diff --git a/packages-exp/auth-exp/src/core/user/user_impl.test.ts b/packages-exp/auth-exp/src/core/user/user_impl.test.ts new file mode 100644 index 00000000000..ca4b64190fd --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/user_impl.test.ts @@ -0,0 +1,244 @@ +/** + * @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 { mockEndpoint } from '../../../test/api/helper'; +import { makeJWT } from '../../../test/jwt'; +import { testAuth } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { APIUserInfo } from '../../api/account_management/account'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { StsTokenManager } from './token_manager'; +import { UserImpl } from './user_impl'; + +use(sinonChai); +use(chaiAsPromised); +use(sinonChai); + +describe('core/user/user_impl', () => { + let auth: Auth; + let stsTokenManager: StsTokenManager; + + beforeEach(async () => { + auth = await testAuth(); + fetch.setUp(); + stsTokenManager = new StsTokenManager(); + }); + + afterEach(() => { + sinon.restore(); + fetch.tearDown(); + }); + + describe('.constructor', () => { + it('attaches required fields', () => { + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + expect(user.auth).to.eq(auth); + expect(user.uid).to.eq('uid'); + }); + + it('attaches optional fields if provided', () => { + const user = new UserImpl({ + uid: 'uid', + auth, + stsTokenManager, + displayName: 'displayName', + email: 'email', + phoneNumber: 'phoneNumber', + photoURL: 'photoURL' + }); + + expect(user.displayName).to.eq('displayName'); + expect(user.email).to.eq('email'); + expect(user.phoneNumber).to.eq('phoneNumber'); + expect(user.photoURL).to.eq('photoURL'); + }); + + it('sets optional fields to null if not provided', () => { + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + expect(user.displayName).to.eq(null); + expect(user.email).to.eq(null); + expect(user.phoneNumber).to.eq(null); + expect(user.photoURL).to.eq(null); + }); + }); + + describe('#getIdToken', () => { + it('returns the raw token if refresh tokens are in order', async () => { + stsTokenManager.updateFromServerResponse({ + idToken: 'id-token-string', + refreshToken: 'refresh-token-string', + expiresIn: '100000' + } as IdTokenResponse); + + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + const token = await user.getIdToken(); + expect(token).to.eq('id-token-string'); + expect(user.refreshToken).to.eq('refresh-token-string'); + }); + }); + + describe('#getIdTokenResult', () => { + // Smoke test; comprehensive tests in id_token_result.test.ts + it('calls through to getIdTokenResult', async () => { + const token = { + 'iat': String(new Date('May 1, 2020').getTime() / 1000), + 'auth_time': String(new Date('May 2, 2020').getTime() / 1000), + 'exp': String(new Date('May 3, 2020').getTime() / 1000) + }; + + const jwt = makeJWT(token); + + stsTokenManager.updateFromServerResponse({ + idToken: jwt, + refreshToken: 'refresh-token-string', + expiresIn: '100000' + } as IdTokenResponse); + + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + const tokenResult = await user.getIdTokenResult(); + expect(tokenResult).to.eql({ + issuedAtTime: new Date('May 1, 2020').toUTCString(), + authTime: new Date('May 2, 2020').toUTCString(), + expirationTime: new Date('May 3, 2020').toUTCString(), + token: jwt, + claims: token, + signInProvider: null, + signInSecondFactor: null + }); + }); + }); + + describe('#delete', () => { + it('calls delete endpoint', async () => { + stsTokenManager.updateFromServerResponse({ + idToken: 'id-token', + refreshToken: 'refresh-token-string', + expiresIn: '100000' + } as IdTokenResponse); + const user = new UserImpl({ uid: 'uid', auth, stsTokenManager }); + const endpoint = mockEndpoint(Endpoint.DELETE_ACCOUNT, {}); + const signOut = sinon.stub(auth, 'signOut'); + + await user.delete(); + expect(endpoint.calls[0].request).to.eql({ + idToken: 'id-token' + }); + expect(signOut).to.have.been.called; + expect(stsTokenManager.refreshToken).to.be.null; + }); + }); + + describe('.fromPlainObject', () => { + const errorString = + 'Firebase: An internal AuthError has occurred. (auth/internal-error).'; + + it('throws an error if uid is not present', () => { + expect(() => UserImpl.fromPlainObject(auth, { name: 'foo' })).to.throw( + FirebaseError, + errorString + ); + }); + + it('throws if a key is not undefined or string', () => { + expect(() => + UserImpl.fromPlainObject(auth, { uid: 'foo', displayName: 3 }) + ).to.throw(FirebaseError, errorString); + }); + + it('fills out a user object properly', () => { + const params = { + uid: 'uid', + stsTokenManager: { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expirationTime: 3 + }, + displayName: 'name', + email: 'email', + phoneNumber: 'number', + photoURL: 'photo' + }; + + const user = UserImpl.fromPlainObject(auth, params); + expect(user.uid).to.eq(params.uid); + expect(user.displayName).to.eq(params.displayName); + expect(user.email).to.eq(params.email); + expect(user.phoneNumber).to.eq(params.phoneNumber); + expect(user.photoURL).to.eq(params.photoURL); + }); + }); + + describe('fromIdTokenResponse', () => { + const idTokenResponse: IdTokenResponse = { + idToken: 'my-id-token', + refreshToken: 'my-refresh-token', + expiresIn: '1234', + localId: 'local-id', + kind: 'my-kind' + }; + + const serverUser: APIUserInfo = { + localId: 'local-id', + displayName: 'display-name', + photoUrl: 'photo-url', + email: 'email', + emailVerified: true, + phoneNumber: 'phone-number', + tenantId: 'tenant-id', + createdAt: 123, + lastLoginAt: 456 + }; + + beforeEach(() => { + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [serverUser] + }); + }); + + it('should initialize a user', async () => { + const user = await UserImpl._fromIdTokenResponse(auth, idTokenResponse); + expect(user.uid).to.eq(idTokenResponse.localId); + expect(await user.getIdToken()).to.eq('my-id-token'); + expect(user.refreshToken).to.eq('my-refresh-token'); + }); + + it('should pull additional user info on the user', async () => { + const user = await UserImpl._fromIdTokenResponse(auth, idTokenResponse); + expect(user.displayName).to.eq('display-name'); + expect(user.phoneNumber).to.eq('phone-number'); + }); + + it('should not trigger additional callbacks', async () => { + const cb = sinon.spy(); + auth.onAuthStateChanged(cb); + await auth.updateCurrentUser(null); + cb.resetHistory(); + + await UserImpl._fromIdTokenResponse(auth, idTokenResponse); + expect(cb).not.to.have.been.called; + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/user/user_impl.ts b/packages-exp/auth-exp/src/core/user/user_impl.ts new file mode 100644 index 00000000000..f48470d4ae0 --- /dev/null +++ b/packages-exp/auth-exp/src/core/user/user_impl.ts @@ -0,0 +1,211 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { deleteAccount } from '../../api/account_management/account'; +import { Auth } from '../../model/auth'; +import { IdTokenResponse } from '../../model/id_token'; +import { User } from '../../model/user'; +import { PersistedBlob } from '../persistence'; +import { assert } from '../util/assert'; +import { getIdTokenResult } from './id_token_result'; +import { _reloadWithoutSaving, reload } from './reload'; +import { StsTokenManager } from './token_manager'; + +export interface UserParameters { + uid: string; + auth: Auth; + stsTokenManager: StsTokenManager; + + displayName?: string; + email?: string; + phoneNumber?: string; + photoURL?: string; + isAnonymous?: boolean; +} + +function assertStringOrUndefined( + assertion: unknown, + appName: string +): asserts assertion is string | undefined { + assert( + typeof assertion === 'string' || typeof assertion === 'undefined', + appName + ); +} + +export class UserImpl implements User { + // For the user object, provider is always Firebase. + readonly providerId = externs.ProviderId.FIREBASE; + stsTokenManager: StsTokenManager; + + uid: string; + auth: Auth; + emailVerified = false; + tenantId = null; + metadata = {}; + providerData = []; + + // Optional fields from UserInfo + displayName: string | null; + email: string | null; + phoneNumber: string | null; + photoURL: string | null; + isAnonymous: boolean = false; + + constructor({ uid, auth, stsTokenManager, ...opt }: UserParameters) { + this.uid = uid; + this.auth = auth; + this.stsTokenManager = stsTokenManager; + this.displayName = opt.displayName || null; + this.email = opt.email || null; + this.phoneNumber = opt.phoneNumber || null; + this.photoURL = opt.photoURL || null; + this.isAnonymous = opt.isAnonymous || false; + } + + async getIdToken(forceRefresh?: boolean): Promise { + const tokens = await this.stsTokenManager.getToken(this.auth, forceRefresh); + assert(tokens, this.auth.name); + + const { accessToken, wasRefreshed } = tokens; + + if (wasRefreshed) { + await this.auth._persistUserIfCurrent(this); + this.auth._notifyListenersIfCurrent(this); + } + + return accessToken; + } + + getIdTokenResult(forceRefresh?: boolean): Promise { + return getIdTokenResult(this, forceRefresh); + } + + reload(): Promise { + return reload(this); + } + + async _updateTokensIfNecessary( + response: IdTokenResponse, + reload = false + ): Promise { + let tokensRefreshed = false; + if ( + response.idToken && + response.idToken !== this.stsTokenManager.accessToken + ) { + this.stsTokenManager.updateFromServerResponse(response); + tokensRefreshed = true; + } + + if (reload) { + await _reloadWithoutSaving(this); + } + + await this.auth._persistUserIfCurrent(this); + if (tokensRefreshed) { + this.auth._notifyListenersIfCurrent(this); + } + } + + async delete(): Promise { + const idToken = await this.getIdToken(); + await deleteAccount(this.auth, { idToken }); + this.stsTokenManager.clearRefreshToken(); + + // TODO: Determine if cancellable-promises are necessary to use in this class so that delete() + // cancels pending actions... + + return this.auth.signOut(); + } + + toPlainObject(): PersistedBlob { + return { + uid: this.uid, + stsTokenManager: this.stsTokenManager.toPlainObject(), + displayName: this.displayName || undefined, + email: this.email || undefined, + phoneNumber: this.phoneNumber || undefined, + photoURL: this.phoneNumber || undefined + }; + } + + get refreshToken(): string { + return this.stsTokenManager.refreshToken || ''; + } + + static fromPlainObject(auth: Auth, object: PersistedBlob): User { + const { + uid, + stsTokenManager: plainObjectTokenManager, + displayName, + email, + phoneNumber, + photoURL + } = object; + + assert(uid && plainObjectTokenManager, auth.name); + + const stsTokenManager = StsTokenManager.fromPlainObject( + auth.name, + plainObjectTokenManager as PersistedBlob + ); + + assert(typeof uid === 'string', auth.name); + assertStringOrUndefined(displayName, auth.name); + assertStringOrUndefined(email, auth.name); + assertStringOrUndefined(phoneNumber, auth.name); + assertStringOrUndefined(photoURL, auth.name); + return new UserImpl({ + uid, + auth, + stsTokenManager, + displayName, + email, + phoneNumber, + photoURL + }); + } + + /** + * Initialize a User from an idToken server response + * @param auth + * @param idTokenResponse + */ + static async _fromIdTokenResponse( + auth: Auth, + idTokenResponse: IdTokenResponse, + isAnonymous: boolean = false + ): Promise { + const stsTokenManager = new StsTokenManager(); + stsTokenManager.updateFromServerResponse(idTokenResponse); + + // Initialize the Firebase Auth user. + const user = new UserImpl({ + uid: idTokenResponse.localId, + auth, + stsTokenManager, + isAnonymous + }); + + // Updates the user info and data and resolves with a user instance. + await _reloadWithoutSaving(user); + return user; + } +} diff --git a/packages-exp/auth-exp/src/core/util/assert.ts b/packages-exp/auth-exp/src/core/util/assert.ts new file mode 100644 index 00000000000..c8ca4cb1faa --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/assert.ts @@ -0,0 +1,79 @@ +/** + * @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 { AuthErrorCode, AUTH_ERROR_FACTORY } from '../errors'; +import { _logError } from './log'; + +/** + * Unconditionally fails, throwing a developer facing INTERNAL_ERROR + * + * @param appName App name for tagging the error + * @throws FirebaseError + */ +export function fail(appName: string, errorCode: AuthErrorCode): never { + throw AUTH_ERROR_FACTORY.create(errorCode, { appName }); +} + +/** + * Verifies the given condition and fails if false, throwing a developer facing error + * + * @param assertion + * @param appName + */ +export function assert( + assertion: unknown, + appName: string, + errorCode = AuthErrorCode.INTERNAL_ERROR +): asserts assertion { + if (!assertion) { + fail(appName, errorCode); + } +} + +/** + * Unconditionally fails, throwing an internal error with the given message. + * + * @param failure type of failure encountered + * @throws Error + */ +export function debugFail(failure: string): never { + // Log the failure in addition to throw an exception, just in case the + // exception is swallowed. + const message = `INTERNAL ASSERTION FAILED: ` + failure; + _logError(message); + + // NOTE: We don't use FirebaseError here because these are internal failures + // that cannot be handled by the user. (Also it would create a circular + // dependency between the error and assert modules which doesn't work.) + throw new Error(message); +} + +/** + * Fails if the given assertion condition is false, throwing an Error with the + * given message if it did. + * + * @param assertion + * @param message + */ +export function debugAssert( + assertion: unknown, + message: string +): asserts assertion { + if (!assertion) { + debugFail(message); + } +} diff --git a/packages-exp/auth-exp/src/core/util/browser.test.ts b/packages-exp/auth-exp/src/core/util/browser.test.ts new file mode 100644 index 00000000000..cea528cb6bf --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/browser.test.ts @@ -0,0 +1,99 @@ +/** + * @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 } from 'chai'; +import { _getBrowserName, BrowserName } from './browser'; + +describe('core/util/_getBrowserName', () => { + it('should recognize Opera', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.110 Safari/537.36 OPR/36.0.2130.74'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.OPERA); + }); + + it('should recognize IE', () => { + const userAgent = + 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C)'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.IE); + }); + + it('should recognize Edge', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10240'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.EDGE); + }); + + it('should recognize Firefox', () => { + const userAgent = + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:46.0) Gecko/20100101 Firefox/46.0'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.FIREFOX); + }); + + it('should recognize Silk', () => { + const userAgent = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Silk/44.1.54 like Chrome/44.0.2403.63 Safari/537.36'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.SILK); + }); + + it('should recognize Safari', () => { + const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11-4) AppleWebKit/601.5.17 (KHTML, like Gecko) Version/9.1 Safari/601.5.17'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.SAFARI); + }); + + it('should recognize Chrome', () => { + const userAgent = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.94 Safari/537.36'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.CHROME); + }); + + it('should recognize Android', () => { + const userAgent = + 'Mozilla/5.0 (Linux; U; Android 4.0.3; ko-kr; LG-L160L Build/IML74K) AppleWebkit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.ANDROID); + }); + + it('should recognize Blackberry', () => { + const userAgent = + 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.BLACKBERRY); + }); + + it('should recognize IE Mobile', () => { + const userAgent = + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0;Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 920)'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.IEMOBILE); + }); + + it('should recognize WebOS', () => { + const userAgent = + 'Mozilla/5.0 (webOS/1.3; U; en-US) AppleWebKit/525.27.1 (KHTML, like Gecko) Version/1.0 Safari/525.27.1 Desktop/1.0'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.WEBOS); + }); + + it('should recognize an unlisted browser', () => { + const userAgent = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Awesome/2.0.012'; + expect(_getBrowserName(userAgent)).to.eq('Awesome'); + }); + + it('should default to Other', () => { + const userAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_2 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Mobile/12D508 [FBAN/FBIOS;FBAV/27.0.0.10.12;FBBV/8291884;FBDV/iPhone7,1;FBMD/iPhone;FBSN/iPhone OS;FBSV/8.2;FBSS/3; FBCR/vodafoneIE;FBID/phone;FBLC/en_US;FBOP/5]'; + expect(_getBrowserName(userAgent)).to.eq(BrowserName.OTHER); + }); +}); diff --git a/packages-exp/auth-exp/src/core/util/browser.ts b/packages-exp/auth-exp/src/core/util/browser.ts new file mode 100644 index 00000000000..cf4294fefcc --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/browser.ts @@ -0,0 +1,84 @@ +/** + * @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. + */ + +/** + * Enums for Browser name. + */ +export enum BrowserName { + ANDROID = 'Android', + BLACKBERRY = 'Blackberry', + EDGE = 'Edge', + FIREFOX = 'Firefox', + IE = 'IE', + IEMOBILE = 'IEMobile', + OPERA = 'Opera', + OTHER = 'Other', + CHROME = 'Chrome', + SAFARI = 'Safari', + SILK = 'Silk', + WEBOS = 'Webos' +} + +/** + * Determine the browser for the purposes of reporting usage to the API + */ +export function _getBrowserName(userAgent: string): BrowserName | string { + const ua = userAgent.toLowerCase(); + if (ua.includes('opera/') || ua.includes('opr/') || ua.includes('opios/')) { + return BrowserName.OPERA; + } else if (ua.includes('iemobile')) { + // Windows phone IEMobile browser. + return BrowserName.IEMOBILE; + } else if (ua.includes('msie') || ua.includes('trident/')) { + return BrowserName.IE; + } else if (ua.includes('edge/')) { + return BrowserName.EDGE; + } else if (ua.includes('firefox/')) { + return BrowserName.FIREFOX; + } else if (ua.includes('silk/')) { + return BrowserName.SILK; + } else if (ua.includes('blackberry')) { + // Blackberry browser. + return BrowserName.BLACKBERRY; + } else if (ua.includes('webos')) { + // WebOS default browser. + return BrowserName.WEBOS; + } else if ( + ua.includes('safari/') && + !ua.includes('chrome/') && + !ua.includes('crios/') && + !ua.includes('android') + ) { + return BrowserName.SAFARI; + } else if ( + (ua.includes('chrome/') || ua.includes('crios/')) && + !ua.includes('edge/') + ) { + return BrowserName.CHROME; + } else if (ua.includes('android')) { + // Android stock browser. + return BrowserName.ANDROID; + } else { + // Most modern browsers have name/version at end of user agent string. + const re = /([a-zA-Z\d\.]+)\/[a-zA-Z\d\.]*$/; + const matches = userAgent.match(re); + if (matches?.length === 2) { + return matches[1]; + } + } + return BrowserName.OTHER; +} diff --git a/packages-exp/auth-exp/src/core/util/delay.test.ts b/packages-exp/auth-exp/src/core/util/delay.test.ts new file mode 100644 index 00000000000..939ac98c588 --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/delay.test.ts @@ -0,0 +1,55 @@ +/** + * @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 * as util from '@firebase/util'; +import { expect } from 'chai'; +import { restore, stub } from 'sinon'; +import { Delay, _OFFLINE_DELAY_MS } from './delay'; +import * as navigator from './navigator'; + +describe('core/util/delay', () => { + const SHORT_DELAY = 30_000; + const LONG_DELAY = 60_000; + + afterEach(restore); + + it('should return the short delay in browser environments', () => { + const delay = new Delay(SHORT_DELAY, LONG_DELAY); + expect(delay.get()).to.eq(SHORT_DELAY); + }); + + it('should return the long delay in Cordova environments', () => { + const mock = stub(util, 'isMobileCordova'); + mock.callsFake(() => true); + const delay = new Delay(SHORT_DELAY, LONG_DELAY); + expect(delay.get()).to.eq(LONG_DELAY); + }); + + it('should return the long delay in React Native environments', () => { + const mock = stub(util, 'isReactNative'); + mock.callsFake(() => true); + const delay = new Delay(SHORT_DELAY, LONG_DELAY); + expect(delay.get()).to.eq(LONG_DELAY); + }); + + it('should return quicker when offline', () => { + const mock = stub(navigator, '_isOnline'); + mock.callsFake(() => false); + const delay = new Delay(SHORT_DELAY, LONG_DELAY); + expect(delay.get()).to.eq(_OFFLINE_DELAY_MS); + }); +}); diff --git a/packages-exp/auth-exp/src/core/util/delay.ts b/packages-exp/auth-exp/src/core/util/delay.ts new file mode 100644 index 00000000000..dae81f2d8c7 --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/delay.ts @@ -0,0 +1,56 @@ +/** + * @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 { isMobileCordova, isReactNative } from '@firebase/util'; +import { _isOnline } from './navigator'; +import { debugAssert } from './assert'; + +export const _OFFLINE_DELAY_MS = 5000; + +/** + * A structure to help pick between a range of long and short delay durations + * depending on the current environment. In general, the long delay is used for + * mobile environments whereas short delays are used for desktop environments. + */ +export class Delay { + // The default value for the offline delay timeout in ms. + + private readonly isMobile: boolean; + constructor( + private readonly shortDelay: number, + private readonly longDelay: number + ) { + // Internal error when improperly initialized. + debugAssert( + longDelay > shortDelay, + 'Short delay should be less than long delay!' + ); + this.isMobile = isMobileCordova() || isReactNative(); + } + + get(): number { + if (!_isOnline()) { + // Pick the shorter timeout. + return Math.min(_OFFLINE_DELAY_MS, this.shortDelay); + } + // If running in a mobile environment, return the long delay, otherwise + // return the short delay. + // This could be improved in the future to dynamically change based on other + // variables instead of just reading the current environment. + return this.isMobile ? this.longDelay : this.shortDelay; + } +} diff --git a/packages-exp/auth-exp/src/core/util/location.ts b/packages-exp/auth-exp/src/core/util/location.ts new file mode 100644 index 00000000000..ebec4b71f7d --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/location.ts @@ -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 function _getCurrentUrl(): string { + return self?.location?.href || ''; +} + +export function _isHttpOrHttps(): boolean { + return _getCurrentScheme() === 'http:' || _getCurrentScheme() === 'https:'; +} + +export function _getCurrentScheme(): string | null { + return self?.location?.protocol || null; +} diff --git a/packages-exp/auth-exp/src/core/util/log.ts b/packages-exp/auth-exp/src/core/util/log.ts new file mode 100644 index 00000000000..c0d6d85ef7a --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/log.ts @@ -0,0 +1,44 @@ +/** + * @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 { Logger, LogLevel } from '@firebase/logger'; +import { SDK_VERSION } from '@firebase/app-exp'; + +export { LogLevel }; + +const logClient = new Logger('@firebase/auth-exp'); + +// Helper methods are needed because variables can't be exported as read/write +export function _getLogLevel(): LogLevel { + return logClient.logLevel; +} + +export function _setLogLevel(newLevel: LogLevel): void { + logClient.logLevel = newLevel; +} + +export function _logDebug(msg: string, ...args: string[]): void { + if (logClient.logLevel <= LogLevel.DEBUG) { + logClient.debug(`Auth (${SDK_VERSION}): ${msg}`, ...args); + } +} + +export function _logError(msg: string, ...args: string[]): void { + if (logClient.logLevel <= LogLevel.ERROR) { + logClient.error(`Auth (${SDK_VERSION}): ${msg}`, ...args); + } +} diff --git a/packages-exp/auth-exp/src/core/util/navigator.ts b/packages-exp/auth-exp/src/core/util/navigator.ts new file mode 100644 index 00000000000..e0c1f1330b2 --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/navigator.ts @@ -0,0 +1,56 @@ +/** + * @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 { isBrowserExtension } from '@firebase/util'; +import { _isHttpOrHttps } from './location'; + +/** + * For some reason the TS library doesn't know about NetworkInformation + */ +interface NetworkInformation { + downlink?: number; + downlinkMax?: number; + effectiveType?: string; + rtt?: number; + saveData?: boolean; + type?: string; +} +interface StandardNavigator extends Navigator { + connection: NetworkInformation; +} + +/** + * Determine whether the browser is working online + */ +export function _isOnline(): boolean { + if ( + navigator && + typeof navigator.onLine === 'boolean' && + // Apply only for traditional web apps and Chrome extensions. + // This is especially true for Cordova apps which have unreliable + // navigator.onLine behavior unless cordova-plugin-network-information is + // installed which overwrites the native navigator.onLine value and + // defines navigator.connection. + (_isHttpOrHttps() || + isBrowserExtension() || + typeof (navigator as StandardNavigator).connection !== 'undefined') + ) { + return navigator.onLine; + } + // If we can't determine the state, assume it is online. + return true; +} diff --git a/packages-exp/auth-exp/src/core/util/providers.ts b/packages-exp/auth-exp/src/core/util/providers.ts new file mode 100644 index 00000000000..354ecb72706 --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/providers.ts @@ -0,0 +1,33 @@ +/** + * @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 ProviderAssociatedObject { + providerId?: string; +} + +/** + * Takes a set of UserInfo provider data and converts it to a set of names + */ +export function providerDataAsNames( + providerData: T[] +): Set { + return new Set( + providerData + .map(({ providerId }) => providerId) + .filter(pid => !!pid) as string[] + ); +} diff --git a/packages-exp/auth-exp/src/core/util/version.test.ts b/packages-exp/auth-exp/src/core/util/version.test.ts new file mode 100644 index 00000000000..8ea096533b1 --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/version.test.ts @@ -0,0 +1,46 @@ +/** + * @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 { SDK_VERSION } from '@firebase/app-exp'; +import { expect } from 'chai'; +import { ClientPlatform, _getClientVersion } from './version'; + +describe('core/util/_getClientVersion', () => { + context('browser', () => { + it('should set the correct version', () => { + expect(_getClientVersion(ClientPlatform.BROWSER)).to.eq( + `Chrome/JsCore/${SDK_VERSION}/FirebaseCore-web` + ); + }); + }); + + context('worker', () => { + it('should set the correct version', () => { + expect(_getClientVersion(ClientPlatform.WORKER)).to.eq( + `Chrome-Worker/JsCore/${SDK_VERSION}/FirebaseCore-web` + ); + }); + }); + + context('React Native', () => { + it('should set the correct version', () => { + expect(_getClientVersion(ClientPlatform.REACT_NATIVE)).to.eq( + `ReactNative/JsCore/${SDK_VERSION}/FirebaseCore-web` + ); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/core/util/version.ts b/packages-exp/auth-exp/src/core/util/version.ts new file mode 100644 index 00000000000..ccd43b58094 --- /dev/null +++ b/packages-exp/auth-exp/src/core/util/version.ts @@ -0,0 +1,61 @@ +/** + * @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 { SDK_VERSION } from '@firebase/app-exp'; +import { _getBrowserName } from './browser'; +import { getUA } from '@firebase/util'; + +const CLIENT_IMPLEMENTATION = 'JsCore'; + +export enum ClientPlatform { + BROWSER = 'Browser', + NODE = 'Node', + REACT_NATIVE = 'ReactNative', + WORKER = 'Worker' +} + +enum ClientFramework { + // No other framework used. + DEFAULT = 'FirebaseCore-web', + // Firebase Auth used with FirebaseUI-web. + // TODO: Pass this in when used in conjunction with FirebaseUI + FIREBASEUI = 'FirebaseUI-web' +} + +/* + * Determine the SDK version string + * + * TODO: This should be set on the Auth object during initialization + */ +export function _getClientVersion(clientPlatform: ClientPlatform): string { + let reportedPlatform: string; + switch (clientPlatform) { + case ClientPlatform.BROWSER: + // In a browser environment, report the browser name. + reportedPlatform = _getBrowserName(getUA()); + break; + case ClientPlatform.WORKER: + // Technically a worker runs from a browser but we need to differentiate a + // worker from a browser. + // For example: Chrome-Worker/JsCore/4.9.1/FirebaseCore-web. + reportedPlatform = `${_getBrowserName(getUA())}-${clientPlatform}`; + break; + default: + reportedPlatform = clientPlatform; + } + return `${reportedPlatform}/${CLIENT_IMPLEMENTATION}/${SDK_VERSION}/${ClientFramework.DEFAULT}`; +} diff --git a/packages-exp/auth-exp/src/index.ts b/packages-exp/auth-exp/src/index.ts new file mode 100644 index 00000000000..5a8f415ea3e --- /dev/null +++ b/packages-exp/auth-exp/src/index.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2017 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 * as externs from '@firebase/auth-types-exp'; +import { CompleteFn, ErrorFn, Unsubscribe } from '@firebase/util'; + +// core/auth +export { initializeAuth } from './core/auth/auth_impl'; + +// Non-optional auth methods. +export function setPersistence( + auth: externs.Auth, + persistence: externs.Persistence +): void { + auth.setPersistence(persistence); +} +export function onIdTokenChanged( + auth: externs.Auth, + nextOrObserver: externs.NextOrObserver, + error?: ErrorFn, + completed?: CompleteFn +): Unsubscribe { + return auth.onIdTokenChanged(nextOrObserver, error, completed); +} +export function onAuthStateChanged( + auth: externs.Auth, + nextOrObserver: externs.NextOrObserver, + error?: ErrorFn, + completed?: CompleteFn +): Unsubscribe { + return auth.onAuthStateChanged(nextOrObserver, error, completed); +} +export function useDeviceLanguage(auth: externs.Auth): void { + auth.useDeviceLanguage(); +} +export function signOut(auth: externs.Auth): Promise { + return auth.signOut(); +} + +// core/persistence +export { + browserLocalPersistence, + browserSessionPersistence +} from './core/persistence/browser'; +export { inMemoryPersistence } from './core/persistence/in_memory'; +export { indexedDBLocalPersistence } from './core/persistence/indexed_db'; + +// core/providers +export { EmailAuthProvider } from './core/providers/email'; +export { PhoneAuthProvider } from './core/providers/phone'; + +// core/strategies +export { signInAnonymously } from './core/strategies/anonymous'; +export { + signInWithCredential, + linkWithCredential, + reauthenticateWithCredential +} from './core/strategies/credential'; +export { signInWithCustomToken } from './core/strategies/custom_token'; +export { + sendPasswordResetEmail, + confirmPasswordReset, + applyActionCode, + checkActionCode, + verifyPasswordResetCode, + createUserWithEmailAndPassword, + signInWithEmailAndPassword +} from './core/strategies/email_and_password'; +export { + sendSignInLinkToEmail, + isSignInWithEmailLink, + signInWithEmailLink +} from './core/strategies/email_link'; +export { + fetchSignInMethodsForEmail, + sendEmailVerification +} from './core/strategies/email'; +export { + signInWithPhoneNumber, + linkWithPhoneNumber, + reauthenticateWithPhoneNumber +} from './core/strategies/phone'; + +// core +export { ActionCodeURL, parseActionCodeURL } from './core/action_code_url'; + +// core/user +export { + updateProfile, + updateEmail, + updatePassword +} from './core/user/account_info'; +export { getIdToken, getIdTokenResult } from './core/user/id_token_result'; +export { unlink } from './core/user/unlink'; + +export { RecaptchaVerifier } from './platform_browser/recaptcha/recaptcha_verifier'; + +// Non-optional user methods. +export { reload } from './core/user/reload'; +export async function deleteUser(user: externs.User): Promise { + return user.delete(); +} diff --git a/packages-exp/auth-exp/src/model/application_verifier.d.ts b/packages-exp/auth-exp/src/model/application_verifier.d.ts new file mode 100644 index 00000000000..595ff025e05 --- /dev/null +++ b/packages-exp/auth-exp/src/model/application_verifier.d.ts @@ -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. + */ + +import * as externs from '@firebase/auth-types-exp'; + +export interface ApplicationVerifier extends externs.ApplicationVerifier { + _reset(): void; +} diff --git a/packages-exp/auth-exp/src/model/auth.d.ts b/packages-exp/auth-exp/src/model/auth.d.ts new file mode 100644 index 00000000000..b3c7bf5402f --- /dev/null +++ b/packages-exp/auth-exp/src/model/auth.d.ts @@ -0,0 +1,49 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; +import { ErrorFn, CompleteFn, Unsubscribe } from '@firebase/util'; + +import { User } from './user'; + +export type AppName = string; +export type ApiKey = string; +export type AuthDomain = string; + +export interface Auth extends externs.Auth { + currentUser: User | null; + readonly name: AppName; + _isInitialized: boolean; + + updateCurrentUser(user: User | null): Promise; + onAuthStateChanged( + nextOrObserver: externs.NextOrObserver, + error?: ErrorFn, + completed?: CompleteFn + ): Unsubscribe; + onIdTokenChanged( + nextOrObserver: externs.NextOrObserver, + error?: ErrorFn, + completed?: CompleteFn + ): Unsubscribe; + _notifyListenersIfCurrent(user: User): void; + _persistUserIfCurrent(user: User): Promise; +} + +export interface Dependencies { + persistence?: externs.Persistence | externs.Persistence[]; +} diff --git a/packages-exp/auth-exp/src/model/id_token.d.ts b/packages-exp/auth-exp/src/model/id_token.d.ts new file mode 100644 index 00000000000..089748be25c --- /dev/null +++ b/packages-exp/auth-exp/src/model/id_token.d.ts @@ -0,0 +1,74 @@ +/** + * @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 { ProviderId } from '@firebase/auth-types-exp'; + +/** + * Raw encoded JWT + */ +export type IdToken = string; + +/** + * Raw parsed JWT + */ +export interface ParsedIdToken { + iss: string; + aud: string; + exp: number; + sub: string; + iat: number; + email?: string; + verified: boolean; + providerId?: string; + tenantId?: string; + anonymous: boolean; + federatedId?: string; + displayName?: string; + photoURL?: string; + toString(): string; +} + +/** + * IdToken as returned by the API + */ +export interface IdTokenResponse { + providerId?: ProviderId; + idToken: IdToken; + refreshToken: string; + expiresIn: string; + localId: string; + + // MFA-specific fields + mfaPendingCredential?: string; + mfaInfo?: APIMFAInfo[]; + isNewUser?: boolean; + rawUserInfo?: string; + screenName?: string | null; + displayName?: string | null; + photoUrl?: string | null; + kind: string; +} + +/** + * MFA Info as returned by the API + */ +export interface APIMFAInfo { + phoneInfo?: string; + mfaEnrollmentId?: string; + displayName?: string; + enrolledAt?: number; +} diff --git a/packages-exp/auth-exp/src/model/user.d.ts b/packages-exp/auth-exp/src/model/user.d.ts new file mode 100644 index 00000000000..8a8a3d18758 --- /dev/null +++ b/packages-exp/auth-exp/src/model/user.d.ts @@ -0,0 +1,57 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { PersistedBlob } from '../core/persistence'; +import { Auth } from './auth'; +import { IdTokenResponse } from './id_token'; + +type MutableUserInfo = { + -readonly [K in keyof externs.UserInfo]: externs.UserInfo[K]; +}; + +export interface User extends externs.User { + uid: string; + displayName: string | null; + email: string | null; + phoneNumber: string | null; + photoURL: string | null; + + auth: Auth; + providerId: externs.ProviderId.FIREBASE; + refreshToken: string; + emailVerified: boolean; + tenantId: string | null; + providerData: MutableUserInfo[]; + metadata: externs.UserMetadata; + + _updateTokensIfNecessary( + response: IdTokenResponse, + reload?: boolean + ): Promise; + + getIdToken(forceRefresh?: boolean): Promise; + getIdTokenResult(forceRefresh?: boolean): Promise; + reload(): Promise; + delete(): Promise; + toPlainObject(): PersistedBlob; +} + +export interface UserCredential extends externs.UserCredential { + user: User; +} diff --git a/packages-exp/auth-exp/src/platform_browser/auth_window.ts b/packages-exp/auth-exp/src/platform_browser/auth_window.ts new file mode 100644 index 00000000000..c445f4b1a50 --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/auth_window.ts @@ -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. + */ + +import { Recaptcha } from './recaptcha/recaptcha'; + +/** + * A specialized window type that melds the normal window type plus the + * various bits we need. The three different blocks that are &'d together + * cant be defined in the same block together. + */ +export type AuthWindow = { + // Standard window types + [T in keyof Window]: Window[T]; +} & { + // Any known / named properties we want to add + grecaptcha?: Recaptcha; +} & { + // A final catch-all for callbacks (which will have random names) that + // we will stick on the window. + [callback: string]: (...args: unknown[]) => void; +}; + +export const AUTH_WINDOW = (window as unknown) as AuthWindow; diff --git a/packages-exp/auth-exp/src/platform_browser/load_js.test.ts b/packages-exp/auth-exp/src/platform_browser/load_js.test.ts new file mode 100644 index 00000000000..9d0d0028169 --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/load_js.test.ts @@ -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'); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/platform_browser/load_js.ts b/packages-exp/auth-exp/src/platform_browser/load_js.ts new file mode 100644 index 00000000000..c94ce8a38fb --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/load_js.ts @@ -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 { + // 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)}`; +} diff --git a/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha.d.ts b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha.d.ts new file mode 100644 index 00000000000..aad5cfc5420 --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha.d.ts @@ -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; +} diff --git a/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_loader.test.ts b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_loader.test.ts new file mode 100644 index 00000000000..9f1be3160c1 --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_loader.test.ts @@ -0,0 +1,148 @@ +/** + * @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 { stubSingleTimeout } from '../../../test/timeout_stub'; +import { Auth } from '../../model/auth'; +import { AUTH_WINDOW } 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'; + +use(chaiAsPromised); +use(sinonChai); + +describe('platform-browser/recaptcha/recaptcha_loader', () => { + let auth: Auth; + + beforeEach(async () => { + auth = await testAuth(); + }); + + afterEach(() => { + sinon.restore(); + delete AUTH_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(() => { + triggerNetworkTimeout = stubSingleTimeout(networkTimeoutId); + + 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 { + AUTH_WINDOW[_JSLOAD_CALLBACK](); + } + + it('clears the network timeout', () => { + sinon.spy(AUTH_WINDOW, 'clearTimeout'); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + loader.load(auth); + spoofJsLoad(); + expect(AUTH_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 mockRecaptcha = new MockReCaptcha(auth); + const oldRenderMethod = mockRecaptcha.render; + AUTH_WINDOW.grecaptcha = mockRecaptcha; + 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); + AUTH_WINDOW.grecaptcha = new MockReCaptcha(auth); + spoofJsLoad(); + await promise; + // Notice no call to spoofJsLoad.. + expect(await loader.load(auth)).to.eq(AUTH_WINDOW.grecaptcha); + }); + + it('returns immediately if grecaptcha is already set on window', async () => { + AUTH_WINDOW.grecaptcha = new MockReCaptcha(auth); + const loader = new ReCaptchaLoaderImpl(); + expect(await loader.load(auth)).to.eq(AUTH_WINDOW.grecaptcha); + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_loader.ts b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_loader.ts new file mode 100644 index 00000000000..65383d70a94 --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_loader.ts @@ -0,0 +1,135 @@ +/** + * @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 { querystring } from '@firebase/util'; + +import { AUTH_ERROR_FACTORY, AuthErrorCode } from '../../core/errors'; +import { Delay } from '../../core/util/delay'; +import { Auth } from '../../model/auth'; +import { AUTH_WINDOW } from '../auth_window'; +import * as jsHelpers from '../load_js'; +import { Recaptcha } from './recaptcha'; +import { MockReCaptcha } from './recaptcha_mock'; + +// ReCaptcha will load using the same callback, so the callback function needs +// to be kept around +export const _JSLOAD_CALLBACK = jsHelpers._generateCallbackName('rcb'); +const NETWORK_TIMEOUT_DELAY = new Delay(30000, 60000); +const RECAPTCHA_BASE = 'https://www.google.com/recaptcha/api.js?'; + +export interface ReCaptchaLoader { + load(auth: Auth, hl?: string): Promise; + clearedOneInstance(): void; +} + +/** + * Loader for the GReCaptcha library. There should only ever be one of this. + */ +export class ReCaptchaLoaderImpl implements ReCaptchaLoader { + private hostLanguage = ''; + private counter = 0; + private readonly librarySeparatelyLoaded = !!AUTH_WINDOW.grecaptcha; + + load(auth: Auth, hl = ''): Promise { + if (this.shouldResolveImmediately(hl)) { + return Promise.resolve(AUTH_WINDOW.grecaptcha!); + } + return new Promise((resolve, reject) => { + const networkTimeout = AUTH_WINDOW.setTimeout(() => { + reject( + AUTH_ERROR_FACTORY.create(AuthErrorCode.NETWORK_REQUEST_FAILED, { + appName: auth.name + }) + ); + }, NETWORK_TIMEOUT_DELAY.get()); + + AUTH_WINDOW[_JSLOAD_CALLBACK] = () => { + AUTH_WINDOW.clearTimeout(networkTimeout); + delete AUTH_WINDOW[_JSLOAD_CALLBACK]; + + const recaptcha = AUTH_WINDOW.grecaptcha; + + if (!recaptcha) { + reject( + AUTH_ERROR_FACTORY.create(AuthErrorCode.INTERNAL_ERROR, { + appName: auth.name + }) + ); + return; + } + + // Wrap the greptcha render function so that we know if the developer has + // called it separately + const render = recaptcha.render; + recaptcha.render = (container, params) => { + const widgetId = render(container, params); + this.counter++; + return widgetId; + }; + + this.hostLanguage = hl; + resolve(recaptcha); + }; + + const url = `${RECAPTCHA_BASE}?${querystring({ + onload: _JSLOAD_CALLBACK, + render: 'explicit', + hl + })}`; + + jsHelpers._loadJS(url).catch(() => { + clearTimeout(networkTimeout); + reject( + AUTH_ERROR_FACTORY.create(AuthErrorCode.INTERNAL_ERROR, { + appName: auth.name + }) + ); + }); + }); + } + + clearedOneInstance(): void { + this.counter--; + } + + private shouldResolveImmediately(hl: string): boolean { + // We can resolve immediately if: + // • grecaptcha is already defined AND ( + // 1. the requested language codes are the same OR + // 2. there exists already a ReCaptcha on the page + // 3. the library was already loaded by the app + // In cases (2) and (3), we _can't_ reload as it would break the recaptchas + // that are already in the page + return ( + !!AUTH_WINDOW.grecaptcha && + (hl === this.hostLanguage || + this.counter > 0 || + this.librarySeparatelyLoaded) + ); + } +} + +class MockReCaptchaLoaderImpl implements ReCaptchaLoader { + async load(auth: Auth): Promise { + return new MockReCaptcha(auth); + } + + clearedOneInstance(): void {} +} + +export const MOCK_RECAPTCHA_LOADER: ReCaptchaLoader = new MockReCaptchaLoaderImpl(); +export const RECAPTCHA_LOADER: ReCaptchaLoader = new ReCaptchaLoaderImpl(); diff --git a/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_mock.test.ts b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_mock.test.ts new file mode 100644 index 00000000000..6d6430af857 --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_mock.test.ts @@ -0,0 +1,237 @@ +/** + * @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 { stubTimeouts, TimerMap } from '../../../test/timeout_stub'; +import { Auth } from '../../model/auth'; +import { + _EXPIRATION_TIME_MS, + _SOLVE_TIME_MS, + _WIDGET_ID_START, + MockReCaptcha, + MockWidget, + Widget +} from './recaptcha_mock'; + +use(sinonChai); +use(chaiAsPromised); + +describe('platform-browser/recaptcha/recaptcha_mock', () => { + let container: HTMLElement; + let auth: Auth; + + beforeEach(async () => { + container = document.createElement('div'); + auth = await testAuth(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('MockRecaptcha', () => { + let rc: MockReCaptcha; + let widget: Widget; + + beforeEach(async () => { + rc = new MockReCaptcha(auth); + widget = { + getResponse: sinon.stub(), + delete: sinon.stub(), + execute: sinon.stub() + }; + }); + + context('#reset', () => { + it('resets the default widget if no id provided', () => { + rc._widgets.set(_WIDGET_ID_START, widget); + rc.reset(); + expect(widget.delete).to.have.been.called; + expect(rc._widgets).to.be.empty; + }); + + it('resets and removes the widgetId only if passed in', () => { + const widget2 = { + getResponse: sinon.stub(), + delete: sinon.stub(), + execute: sinon.stub() + }; + rc._widgets.set(_WIDGET_ID_START, widget); + rc._widgets.set(_WIDGET_ID_START + 1, widget2); + + rc.reset(_WIDGET_ID_START + 1); + expect(widget2.delete).to.have.been.called; + expect(widget.delete).not.to.have.been.called; + expect(rc._widgets.get(_WIDGET_ID_START)).to.eq(widget); + expect(rc._widgets.size).to.eq(1); + }); + }); + + context('#render', () => { + // These tests all use invisible recaptcha to prevent the mock widget + // from setting timers + const params = { size: 'invisible' }; + + it('adds a mock widget object to the set and returns the id', () => { + const id = rc.render(container, params); + expect(id).to.eq(_WIDGET_ID_START); + expect(rc._widgets.get(_WIDGET_ID_START)).to.not.be.undefined; + }); + + it('sequentially creates new widgets', () => { + rc.render(container, params); + rc.render(container, params); + expect(rc._widgets.get(_WIDGET_ID_START)).to.not.be.undefined; + expect(rc._widgets.get(_WIDGET_ID_START + 1)).to.not.be.undefined; + }); + }); + + context('#getResponse', () => { + it('returns the result from the widget if available', () => { + (widget.getResponse as sinon.SinonStub).returns('widget-result'); + rc._widgets.set(_WIDGET_ID_START, widget); + expect(rc.getResponse()).to.eq('widget-result'); + }); + + it('returns the empty string if the widget does not exist', () => { + expect(rc.getResponse()).to.eq(''); + }); + }); + + context('#execute', () => { + it('calls execute on the underlying widget', async () => { + rc._widgets.set(_WIDGET_ID_START, widget); + await rc.execute(); + expect(widget.execute).to.have.been.called; + }); + + it('returns the empty string', async () => { + expect(await rc.execute()).to.eq(''); + }); + }); + }); + + describe('MockWidget', () => { + context('#constructor', () => { + it('errors if a bad container is passed in', () => { + sinon.stub(document, 'getElementById').returns(null); + expect(() => new MockWidget('foo', 'app-name', {})).to.throw( + FirebaseError, + 'Firebase: Error (auth/argument-error).' + ); + }); + + it('attaches an event listener if invisible', () => { + sinon.spy(container, 'addEventListener'); + void new MockWidget(container, 'app-name', { size: 'invisible' }); + expect(container.addEventListener).to.have.been.called; + }); + }); + + context('#execute', () => { + // Stub out a bunch of stuff on setTimer + let pendingTimers: TimerMap; + let callbacks: { [key: string]: sinon.SinonSpy }; + let widget: MockWidget; + let timeoutStub: sinon.SinonStub; + + beforeEach(() => { + callbacks = { + 'callback': sinon.spy(), + 'expired-callback': sinon.spy() + }; + pendingTimers = stubTimeouts(); + timeoutStub = (window.setTimeout as unknown) as sinon.SinonStub; + widget = new MockWidget(container, auth.name, callbacks); + }); + + it('keeps re-executing with new tokens if expiring', () => { + pendingTimers[_SOLVE_TIME_MS](); + pendingTimers[_EXPIRATION_TIME_MS](); + pendingTimers[_SOLVE_TIME_MS](); + pendingTimers[_EXPIRATION_TIME_MS](); + + expect(callbacks['callback']).to.have.been.calledTwice; + expect(callbacks['callback'].getCall(0).args[0]).not.to.eq( + callbacks['callback'].getCall(1).args[0] + ); + }); + + it('posts callback with a random alphanumeric code', () => { + pendingTimers[_SOLVE_TIME_MS](); + const arg: string = callbacks['callback'].getCall(0).args[0]; + expect(arg).to.be.a('string'); + expect(arg.length).to.equal(50); + }); + + it('expired callback does execute if just solve trips', () => { + pendingTimers[_SOLVE_TIME_MS](); + expect(callbacks['callback']).to.have.been.called; + expect(callbacks['expired-callback']).not.to.have.been.called; + }); + + it('expired callback executes if just expiration timer trips', () => { + pendingTimers[_SOLVE_TIME_MS](); + pendingTimers[_EXPIRATION_TIME_MS](); + expect(callbacks['callback']).to.have.been.called; + expect(callbacks['expired-callback']).to.have.been.called; + }); + + it('throws an error if the widget is deleted', () => { + widget.delete(); + expect(() => widget.execute()).to.throw(Error); + }); + + it('returns immediately if timer is already in flight', () => { + expect(timeoutStub.getCalls().length).to.eq(1); + widget.execute(); + expect(timeoutStub.getCalls().length).to.eq(1); + }); + }); + + context('#delete', () => { + let widget: MockWidget; + beforeEach(() => { + widget = new MockWidget(container, auth.name, {}); + }); + + it('throws if already deleted', () => { + widget.delete(); + expect(() => widget.delete()).to.throw(Error); + }); + + it('clears any timeouts that are set', () => { + const spy = sinon.spy(window, 'clearTimeout'); + widget.delete(); + expect(spy).to.have.been.called; + }); + + it('removes the event listener from the container', () => { + const spy = sinon.spy(container, 'removeEventListener'); + widget.delete(); + expect(spy).to.have.been.called; + }); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_mock.ts b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_mock.ts new file mode 100644 index 00000000000..6eb52ad1594 --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_mock.ts @@ -0,0 +1,160 @@ +/** + * @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 { AuthErrorCode } from '../../core/errors'; +import { assert } from '../../core/util/assert'; +import { Auth } from '../../model/auth'; +import { Parameters, Recaptcha } from './recaptcha'; + +export const _SOLVE_TIME_MS = 500; +export const _EXPIRATION_TIME_MS = 60_000; +export const _WIDGET_ID_START = 1_000_000_000_000; + +export interface Widget { + getResponse: () => string | null; + delete: () => void; + execute: () => void; +} + +export class MockReCaptcha implements Recaptcha { + private counter = _WIDGET_ID_START; + _widgets = new Map(); + + constructor(private readonly auth: Auth) {} + + render(container: string | HTMLElement, parameters?: Parameters): number { + const id = this.counter; + this._widgets.set( + id, + new MockWidget(container, this.auth.name, parameters || {}) + ); + this.counter++; + return id; + } + + reset(optWidgetId?: number): void { + const id = optWidgetId || _WIDGET_ID_START; + void this._widgets.get(id)?.delete(); + this._widgets.delete(id); + } + + getResponse(optWidgetId?: number): string { + const id = optWidgetId || _WIDGET_ID_START; + return this._widgets.get(id)?.getResponse() || ''; + } + + async execute(optWidgetId?: number | string): Promise { + const id: number = (optWidgetId as number) || _WIDGET_ID_START; + void this._widgets.get(id)?.execute(); + return ''; + } +} + +export class MockWidget { + private readonly container: HTMLElement; + private readonly isVisible: boolean; + private timerId: number | null = null; + private deleted = false; + private responseToken: string | null = null; + private readonly clickHandler = (): void => { + this.execute(); + }; + + constructor( + containerOrId: string | HTMLElement, + appName: string, + private readonly params: Parameters + ) { + const container = + typeof containerOrId === 'string' + ? document.getElementById(containerOrId) + : containerOrId; + assert(container, appName, AuthErrorCode.ARGUMENT_ERROR); + + this.container = container; + this.isVisible = this.params.size !== 'invisible'; + if (this.isVisible) { + this.execute(); + } else { + this.container.addEventListener('click', this.clickHandler); + } + } + + getResponse(): string | null { + this.checkIfDeleted(); + return this.responseToken; + } + + delete(): void { + this.checkIfDeleted(); + this.deleted = true; + if (this.timerId) { + clearTimeout(this.timerId); + this.timerId = null; + } + this.container.removeEventListener('click', this.clickHandler); + } + + execute(): void { + this.checkIfDeleted(); + if (this.timerId) { + return; + } + + this.timerId = window.setTimeout(() => { + this.responseToken = generateRandomAlphaNumericString(50); + const { callback, 'expired-callback': expiredCallback } = this.params; + if (callback) { + try { + callback(this.responseToken); + } catch (e) {} + } + + this.timerId = window.setTimeout(() => { + this.timerId = null; + this.responseToken = null; + if (expiredCallback) { + try { + expiredCallback(); + } catch (e) {} + } + + if (this.isVisible) { + this.execute(); + } + }, _EXPIRATION_TIME_MS); + }, _SOLVE_TIME_MS); + } + + private checkIfDeleted(): void { + if (this.deleted) { + throw new Error('reCAPTCHA mock was already deleted!'); + } + } +} + +function generateRandomAlphaNumericString(len: number): string { + const chars = []; + const allowedChars = + '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + for (let i = 0; i < len; i++) { + chars.push( + allowedChars.charAt(Math.floor(Math.random() * allowedChars.length)) + ); + } + return chars.join(''); +} diff --git a/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_verifier.test.ts b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_verifier.test.ts new file mode 100644 index 00000000000..80992f7d666 --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_verifier.test.ts @@ -0,0 +1,202 @@ +/** + * @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 { mockEndpoint } from '../../../test/api/helper'; +import { testAuth } from '../../../test/mock_auth'; +import * as fetch from '../../../test/mock_fetch'; +import { Endpoint } from '../../api'; +import { Auth } from '../../model/auth'; +import { AUTH_WINDOW } from '../auth_window'; +import { Parameters, Recaptcha } from './recaptcha'; +import { _JSLOAD_CALLBACK, MOCK_RECAPTCHA_LOADER } from './recaptcha_loader'; +import { MockReCaptcha } from './recaptcha_mock'; +import { RecaptchaVerifier } from './recaptcha_verifier'; + +use(chaiAsPromised); +use(sinonChai); + +describe('platform_browser/recaptcha/recaptcha_verifier.ts', () => { + let auth: Auth; + let container: HTMLElement; + let verifier: RecaptchaVerifier; + let parameters: Parameters; + + beforeEach(async () => { + fetch.setUp(); + auth = await testAuth(); + auth.languageCode = 'fr'; + container = document.createElement('div'); + parameters = {}; + verifier = new RecaptchaVerifier(container, parameters, auth); + // The verifier will have set the parameters.callback field to be the wrapped callback + + mockEndpoint(Endpoint.GET_RECAPTCHA_PARAM, { + recaptchaSiteKey: 'recaptcha-key' + }); + }); + + afterEach(() => { + sinon.restore(); + fetch.tearDown(); + }); + + context('#render', () => { + it('caches the promise if not completed and returns if called multiple times', () => { + // This will force the loader to never return so the render promise never completes + sinon.stub(MOCK_RECAPTCHA_LOADER, 'load').returns(new Promise(() => {})); + const renderPromise = verifier.render(); + expect(verifier.render()).to.eq(renderPromise); + }); + + it('appends an empty div to the container element', async () => { + expect(container.childElementCount).to.eq(0); + await verifier.render(); + expect(container.childElementCount).to.eq(1); + }); + + it('sets the site key on the parameters object', async () => { + await verifier.render(); + expect(parameters.sitekey).to.eq('recaptcha-key'); + }); + + it('sets loads the recaptcha per the app language code', async () => { + sinon.spy(MOCK_RECAPTCHA_LOADER, 'load'); + await verifier.render(); + expect(MOCK_RECAPTCHA_LOADER.load).to.have.been.calledWith(auth, 'fr'); + }); + + it('calls render on the underlying recaptcha widget', async () => { + const widget = new MockReCaptcha(auth); + sinon.spy(widget, 'render'); + sinon + .stub(MOCK_RECAPTCHA_LOADER, 'load') + .returns(Promise.resolve(widget)); + await verifier.render(); + expect(widget.render).to.have.been.calledWith( + container.children[0], + parameters + ); + }); + + it('in case of error, resets render promise', async () => { + sinon.stub(MOCK_RECAPTCHA_LOADER, 'load').returns(Promise.reject('nope')); + const promise = verifier.render(); + await expect(promise).to.be.rejectedWith('nope'); + expect(verifier.render()).not.to.eq(promise); + }); + }); + + context('#verify', () => { + let recaptcha: Recaptcha; + beforeEach(() => { + recaptcha = new MockReCaptcha(auth); + sinon + .stub(MOCK_RECAPTCHA_LOADER, 'load') + .returns(Promise.resolve(recaptcha)); + }); + + it('returns immediately if response is available', async () => { + sinon.stub(recaptcha, 'getResponse').returns('recaptcha-response'); + expect(await verifier.verify()).to.eq('recaptcha-response'); + }); + + it('resolves with the token in the callback', async () => { + sinon.stub(recaptcha, 'getResponse').returns(''); + const promise = verifier.verify(); + expect(typeof (await promise)).to.eq('string'); + }); + + it('calls existing callback if provided', async () => { + let token = ''; + parameters = { + callback: (t: string): void => { + token = t; + } + }; + + verifier = new RecaptchaVerifier(container, parameters, auth); + const expected = await verifier.verify(); + expect(token).to.eq(expected); + }); + + it('calls existing global function if on the window', async () => { + let token = ''; + AUTH_WINDOW.callbackOnWindowObject = (t: unknown): void => { + token = t as string; + }; + + parameters = { + callback: 'callbackOnWindowObject' + }; + + verifier = new RecaptchaVerifier(container, parameters, auth); + const expected = await verifier.verify(); + expect(token).to.eq(expected); + + delete AUTH_WINDOW.callbackOnWindowObject; + }); + }); + + context('#reset', () => { + it('calls reset on the underlying widget', async () => { + const recaptcha = new MockReCaptcha(auth); + sinon + .stub(MOCK_RECAPTCHA_LOADER, 'load') + .returns(Promise.resolve(recaptcha)); + sinon.spy(recaptcha, 'reset'); + await verifier.render(); + verifier._reset(); + expect(recaptcha.reset).to.have.been.called; + }); + }); + + context('#clear', () => { + it('removes the child node from the container', async () => { + await verifier.render(); + expect(container.children.length).to.eq(1); + verifier.clear(); + expect(container.children.length).to.eq(0); + }); + + it('causes other methods of the verifier to throw if called subsequently', async () => { + verifier.clear(); + expect(() => verifier.clear()).to.throw( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + expect(() => verifier._reset()).to.throw( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + await expect(verifier.render()).to.be.rejectedWith( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + await expect(verifier.verify()).to.be.rejectedWith( + FirebaseError, + 'Firebase: An internal AuthError has occurred. (auth/internal-error).' + ); + }); + }); +}); diff --git a/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_verifier.ts b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_verifier.ts new file mode 100644 index 00000000000..b9d85fdf361 --- /dev/null +++ b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_verifier.ts @@ -0,0 +1,249 @@ +/** + * @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 * as externs from '@firebase/auth-types-exp'; + +import { getRecaptchaParams } from '../../api/authentication/recaptcha'; +import { initializeAuth } from '../../core/auth/auth_impl'; +import { AuthErrorCode } from '../../core/errors'; +import { assert } from '../../core/util/assert'; +import { _isHttpOrHttps } from '../../core/util/location'; +import { Auth } from '../../model/auth'; +import { AUTH_WINDOW } from '../auth_window'; +import { Parameters, Recaptcha } from './recaptcha'; +import { + MOCK_RECAPTCHA_LOADER, + RECAPTCHA_LOADER, + ReCaptchaLoader +} from './recaptcha_loader'; +import { ApplicationVerifier } from '../../model/application_verifier'; + +const DEFAULT_PARAMS: Parameters = { + theme: 'light', + type: 'image' +}; + +type TokenCallback = (token: string) => void; + +export const RECAPTCHA_VERIFIER_TYPE = 'recaptcha'; + +export class RecaptchaVerifier + implements externs.RecaptchaVerifier, ApplicationVerifier { + readonly type = RECAPTCHA_VERIFIER_TYPE; + private readonly auth: Auth; + private readonly appName: string; + private destroyed = false; + private widgetId: number | null = null; + private readonly container: HTMLElement; + private readonly isInvisible: boolean; + private readonly tokenChangeListeners = new Set(); + private renderPromise: Promise | null = null; + + private readonly recaptchaLoader: ReCaptchaLoader; + private recaptcha: Recaptcha | null = null; + + constructor( + containerOrId: HTMLElement | string, + private readonly parameters: Parameters = { + ...DEFAULT_PARAMS + }, + auth?: Auth | null + ) { + this.auth = auth || (initializeAuth() as Auth); + this.appName = this.auth.name; + this.isInvisible = this.parameters.size === 'invisible'; + const container = + typeof containerOrId === 'string' + ? document.getElementById(containerOrId) + : containerOrId; + assert(container, this.appName, AuthErrorCode.ARGUMENT_ERROR); + + this.container = container; + this.parameters.callback = this.makeTokenCallback(this.parameters.callback); + + this.recaptchaLoader = this.auth.settings.appVerificationDisabledForTesting + ? MOCK_RECAPTCHA_LOADER + : RECAPTCHA_LOADER; + + this.validateStartingState(); + // TODO: Figure out if sdk version is needed + } + + async verify(): Promise { + this.assertNotDestroyed(); + const id = await this.render(); + const recaptcha = this.assertedRecaptcha; + + const response = recaptcha.getResponse(id); + if (response) { + return response; + } + + return new Promise(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); + } + }); + } + + render(): Promise { + 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; + } + + _reset(): void { + this.assertNotDestroyed(); + if (this.widgetId !== null) { + this.assertedRecaptcha.reset(this.widgetId); + } + } + + 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.appName, + AuthErrorCode.ARGUMENT_ERROR + ); + assert(document, this.appName, AuthErrorCode.OPERATION_NOT_SUPPORTED); + assert( + this.isInvisible || !this.container.hasChildNodes(), + this.appName, + AuthErrorCode.ARGUMENT_ERROR + ); + } + + 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 = AUTH_WINDOW[existing]; + if (typeof globalFunc === 'function') { + globalFunc(token); + } + } + }; + } + + private assertNotDestroyed(): void { + assert(!this.destroyed, this.appName); + } + + private async makeRenderPromise(): Promise { + 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.assertedRecaptcha.render(container, this.parameters); + } + + return this.widgetId; + } + + private async init(): Promise { + assert(_isHttpOrHttps() && !isWorker(), this.appName); + + await domReady(); + this.recaptcha = await this.recaptchaLoader.load( + this.auth, + this.auth.languageCode || undefined + ); + + const siteKey = await getRecaptchaParams(this.auth); + assert(siteKey, this.appName); + this.parameters.sitekey = siteKey; + } + + private get assertedRecaptcha(): Recaptcha { + assert(this.recaptcha, this.appName); + return this.recaptcha; + } +} + +function isWorker(): boolean { + return ( + typeof AUTH_WINDOW['WorkerGlobalScope'] !== 'undefined' && + typeof AUTH_WINDOW['importScripts'] === 'function' + ); +} + +function domReady(): Promise { + let resolver: (() => void) | null = null; + return new Promise(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; + }); +} diff --git a/packages-exp/auth-exp/test/api/helper.ts b/packages-exp/auth-exp/test/api/helper.ts new file mode 100644 index 00000000000..68118d8d418 --- /dev/null +++ b/packages-exp/auth-exp/test/api/helper.ts @@ -0,0 +1,32 @@ +/** + * @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 { Endpoint } from '../../src/api'; +import { TEST_HOST, TEST_KEY, TEST_SCHEME } from '../mock_auth'; +import { mock, Route } from '../mock_fetch'; + +export function mockEndpoint( + endpoint: Endpoint, + response: object, + status = 200 +): Route { + return mock( + `${TEST_SCHEME}://${TEST_HOST}${endpoint}?key=${TEST_KEY}`, + response, + status + ); +} diff --git a/packages-exp/auth-exp/test/jwt.ts b/packages-exp/auth-exp/test/jwt.ts new file mode 100644 index 00000000000..a8a285f1e35 --- /dev/null +++ b/packages-exp/auth-exp/test/jwt.ts @@ -0,0 +1,23 @@ +/** + * @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 { base64Encode } from '@firebase/util'; + +export function makeJWT(claims: object): string { + const payload = base64Encode(JSON.stringify(claims)); + return `algorithm.${payload}.signature`; +} diff --git a/packages-exp/auth-exp/test/mock_auth.ts b/packages-exp/auth-exp/test/mock_auth.ts new file mode 100644 index 00000000000..a1f4d0519ba --- /dev/null +++ b/packages-exp/auth-exp/test/mock_auth.ts @@ -0,0 +1,89 @@ +/** + * @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 { AuthImpl } from '../src/core/auth/auth_impl'; +import { PersistedBlob } from '../src/core/persistence'; +import { InMemoryPersistence } from '../src/core/persistence/in_memory'; +import { StsTokenManager } from '../src/core/user/token_manager'; +import { UserImpl } from '../src/core/user/user_impl'; +import { Auth } from '../src/model/auth'; +import { User } from '../src/model/user'; + +export const TEST_HOST = 'localhost'; +export const TEST_TOKEN_HOST = 'localhost/token'; +export const TEST_AUTH_DOMAIN = 'localhost'; +export const TEST_SCHEME = 'mock'; +export const TEST_KEY = 'test-api-key'; + +export interface TestAuth extends AuthImpl { + persistenceLayer: MockPersistenceLayer; +} + +class MockPersistenceLayer extends InMemoryPersistence { + lastObjectSet: PersistedBlob | null = null; + + set(key: string, object: PersistedBlob): Promise { + this.lastObjectSet = object; + return super.set(key, object); + } + + remove(key: string): Promise { + this.lastObjectSet = null; + return super.remove(key); + } +} + +export async function testAuth(): Promise { + const persistence = new MockPersistenceLayer(); + const auth: TestAuth = new AuthImpl('test-app', { + apiKey: TEST_KEY, + authDomain: TEST_AUTH_DOMAIN, + apiHost: TEST_HOST, + apiScheme: TEST_SCHEME, + tokenApiHost: TEST_TOKEN_HOST, + sdkClientVersion: 'testSDK/0.0.0' + }) as TestAuth; + + await auth._initializeWithPersistence([persistence]); + auth.persistenceLayer = persistence; + auth.settings.appVerificationDisabledForTesting = true; + return auth; +} + +export function testUser( + auth: Auth | {}, + uid: string, + email?: string, + fakeTokens = false +): User { + // Create a token manager that's valid off the bat to avoid refresh calls + const stsTokenManager = new StsTokenManager(); + if (fakeTokens) { + Object.assign>(stsTokenManager, { + expirationTime: Date.now() + 100_000, + accessToken: 'access-token', + refreshToken: 'refresh-token' + }); + } + + return new UserImpl({ + uid, + auth: auth as Auth, + stsTokenManager, + email + }); +} diff --git a/packages-exp/auth-exp/test/mock_auth_credential.ts b/packages-exp/auth-exp/test/mock_auth_credential.ts new file mode 100644 index 00000000000..44b505d80d0 --- /dev/null +++ b/packages-exp/auth-exp/test/mock_auth_credential.ts @@ -0,0 +1,63 @@ +/** + * @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 { ProviderId, SignInMethod } from '@firebase/auth-types-exp'; + +import { PhoneOrOauthTokenResponse } from '../src/api/authentication/mfa'; +import { AuthCredential } from '../src/core/credentials'; +import { Auth } from '../src/model/auth'; +import { IdTokenResponse } from '../src/model/id_token'; + +export class MockAuthCredential implements AuthCredential { + response?: PhoneOrOauthTokenResponse; + + constructor( + readonly providerId: ProviderId, + readonly signInMethod: SignInMethod + ) {} + + toJSON(): object { + throw new Error('Method not implemented.'); + } + + fromJSON(_json: string | object): AuthCredential | null { + throw new Error('Method not implemented.'); + } + + /** + * For testing purposes only + * @param response + */ + _setIdTokenResponse(response: PhoneOrOauthTokenResponse): void { + this.response = response; + } + + async _getIdTokenResponse(_auth: Auth): Promise { + return this.response!; + } + + async _linkToIdToken( + _auth: Auth, + _idToken: string + ): Promise { + return this.response!; + } + + async _getReauthenticationResolver(_auth: Auth): Promise { + return this.response!; + } +} diff --git a/packages-exp/auth-exp/test/mock_fetch.test.ts b/packages-exp/auth-exp/test/mock_fetch.test.ts new file mode 100644 index 00000000000..db88a57bed3 --- /dev/null +++ b/packages-exp/auth-exp/test/mock_fetch.test.ts @@ -0,0 +1,111 @@ +/** + * @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 } from 'chai'; +import * as mockFetch from './mock_fetch'; + +async function fetchJson(path: string, req?: object): Promise { + const body = req + ? { + body: JSON.stringify(req) + } + : {}; + const request: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + referrerPolicy: 'no-referrer', + ...body + }; + + const response = await fetch(path, request); + return response.json(); +} + +describe('mock fetch utility', () => { + beforeEach(mockFetch.setUp); + afterEach(mockFetch.tearDown); + + describe('matched requests', () => { + it('returns the correct object for multiple routes', async () => { + mockFetch.mock('/a', { a: 1 }); + mockFetch.mock('/b', { b: 2 }); + + expect(await fetchJson('/a')).to.eql({ a: 1 }); + expect(await fetchJson('/b')).to.eql({ b: 2 }); + }); + + it('passes through the status of the mock', async () => { + mockFetch.mock('/not-ok', {}, 500); + expect((await fetch('/not-ok')).status).to.equal(500); + }); + + it('records calls to the mock', async () => { + const someRequest = { + sentence: + 'Buffalo buffalo Buffalo buffalo buffalo buffalo Buffalo buffalo' + }; + + const mock = mockFetch.mock('/word', {}); + await fetchJson('/word', someRequest); + await fetchJson('/word', { a: 'b' }); + await fetch('/word'); + + expect(mock.calls.length).to.equal(3); + expect(mock.calls[0].request).to.eql(someRequest); + expect(mock.calls[1].request).to.eql({ a: 'b' }); + expect(mock.calls[2].request).to.equal(undefined); + }); + }); + + describe('route rejection', () => { + it('if the route is not in the map', () => { + mockFetch.mock('/test', {}); + expect(() => fetch('/not-test')).to.throw( + 'Unknown route being requested: /not-test' + ); + }); + + it('if call is not a string', () => { + mockFetch.mock('/blah', {}); + expect(() => fetch(new Request({} as any))).to.throw( + 'URL passed to fetch was not a string' + ); + }); + }); +}); + +describe('mock fetch utility (no setUp/tearDown)', () => { + it('errors if mock attempted without setup', () => { + expect(() => mockFetch.mock('/test', {})).to.throw( + 'Mock fetch is not set up' + ); + }); + + it('routes do not carry to next run', async () => { + mockFetch.setUp(); + mockFetch.mock('/test', { first: 'first' }); + expect(await fetchJson('/test')).to.eql({ first: 'first' }); + mockFetch.tearDown(); + mockFetch.setUp(); + expect(() => fetch('/test')).to.throw( + 'Unknown route being requested: /test' + ); + mockFetch.tearDown(); + }); +}); diff --git a/packages-exp/auth-exp/test/mock_fetch.ts b/packages-exp/auth-exp/test/mock_fetch.ts new file mode 100644 index 00000000000..a3f8a50c3bc --- /dev/null +++ b/packages-exp/auth-exp/test/mock_fetch.ts @@ -0,0 +1,93 @@ +/** + * @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 { SinonStub, stub } from 'sinon'; + +export interface Call { + request?: object | string; + method?: string; + headers?: HeadersInit; +} + +export interface Route { + response: object; + status: number; + calls: Call[]; +} + +let fetchStub: SinonStub | null = null; +let routes = new Map(); + +// Using a constant rather than a function to enforce the type matches fetch() +const fakeFetch: typeof fetch = (input: RequestInfo, request?: RequestInit) => { + if (typeof input !== 'string') { + throw new Error('URL passed to fetch was not a string'); + } + + if (!routes.has(input)) { + console.error(`Unknown route being requested: ${input}`); + throw new Error(`Unknown route being requested: ${input}`); + } + + // Bang-assertion is fine since we check for routes.has() above + const { response, status, calls } = routes.get(input)!; + + const requestBody = + request?.body && + (request?.headers as any)?.['Content-Type'] === 'application/json' + ? JSON.parse(request.body as string) + : request?.body; + + calls.push({ + request: requestBody, + method: request?.method, + headers: request?.headers + }); + + const blob = new Blob([JSON.stringify(response)]); + return Promise.resolve( + new Response(blob, { + status + }) + ); +}; + +export function setUp(): void { + fetchStub = stub(self, 'fetch'); + fetchStub.callsFake(fakeFetch); +} + +export function mock(url: string, response: object, status = 200): Route { + if (!fetchStub) { + throw new Error('Mock fetch is not set up. Call setUp() first'); + } + + const route: Route = { + response, + status, + calls: [] + }; + + routes.set(url, route); + return route; +} + +export function tearDown(): void { + fetchStub?.restore(); + fetchStub = null; + routes = new Map(); +} diff --git a/packages-exp/auth-exp/test/timeout_stub.ts b/packages-exp/auth-exp/test/timeout_stub.ts new file mode 100644 index 00000000000..a3f1671b11b --- /dev/null +++ b/packages-exp/auth-exp/test/timeout_stub.ts @@ -0,0 +1,67 @@ +/** + * @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 * as sinon from 'sinon'; + +interface TimerTripFn { + (): void; +} + +export interface TimerMap { + [key: number]: TimerTripFn; +} + +/** + * Stubs window.setTimeout and returns a map of the functions that were passed + * in (the map is mutable and will be modified as setTimeout gets called). + * You can use this to manually cause timers to trip. The map is keyed by the + * duration of the timeout + */ +export function stubTimeouts(ids?: number[]): TimerMap { + const callbacks: { [key: number]: TimerTripFn } = {}; + let idCounter = 0; + + sinon.stub(window, 'setTimeout').callsFake((cb: () => void, duration) => { + callbacks[duration] = 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 + const id = ids ? ids[idCounter] : idCounter + 100; + idCounter++; + return id as any; + }); + + return callbacks; +} + +/** + * Similar to stubTimeouts, but for use when there's only one timeout you + * care about + */ +export function stubSingleTimeout(id?: number): TimerTripFn { + const callbacks = stubTimeouts(id ? [id] : undefined); + return () => { + const [key, ...rest] = Object.keys(callbacks).map(Number); + if (rest.length) { + throw new Error( + 'stubSingleTimeout should only be used when a single timeout is set' + ); + } + + callbacks[key](); + }; +} diff --git a/packages-exp/auth-exp/tsconfig.json b/packages-exp/auth-exp/tsconfig.json new file mode 100644 index 00000000000..a06ed9a374c --- /dev/null +++ b/packages-exp/auth-exp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "exclude": [ + "dist/**/*" + ] +} \ No newline at end of file diff --git a/packages-exp/auth-types-exp/README.md b/packages-exp/auth-types-exp/README.md new file mode 100644 index 00000000000..e63106206bf --- /dev/null +++ b/packages-exp/auth-types-exp/README.md @@ -0,0 +1,3 @@ +# @firebase/auth-types-exp + +**This package is not intended for direct usage, and should only be used via the officially supported [firebase](https://www.npmjs.com/package/firebase) package.** diff --git a/packages-exp/auth-types-exp/index.d.ts b/packages-exp/auth-types-exp/index.d.ts new file mode 100644 index 00000000000..5caebb212b4 --- /dev/null +++ b/packages-exp/auth-types-exp/index.d.ts @@ -0,0 +1,413 @@ +/** + * @license + * Copyright 2019 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 { + CompleteFn, + ErrorFn, + NextFn, + Observer, + Unsubscribe +} from '@firebase/util'; + +/** + * Supported providers + */ +export const enum ProviderId { + ANONYMOUS = 'anonymous', + CUSTOM = 'custom', + FACEBOOK = 'facebook.com', + FIREBASE = 'firebase', + GITHUB = 'github.com', + GOOGLE = 'google.com', + PASSWORD = 'password', + PHONE = 'phone', + TWITTER = 'twitter.com' +} + +/** + * Supported sign in methods + */ +export const enum SignInMethod { + ANONYMOUS = 'anonymous', + EMAIL_LINK = 'emailLink', + EMAIL_PASSWORD = 'password', + FACEBOOK = 'facebook.com', + GITHUB = 'github.com', + GOOGLE = 'google.com', + PHONE = 'phone', + TWITTER = 'twitter.com' +} + +/** + * Supported operation types + */ +export const enum OperationType { + LINK = 'link', + REAUTHENTICATE = 'reauthenticate', + SIGN_IN = 'signIn' +} + +/** + * Auth config object + */ +export interface Config { + apiKey: string; + apiHost: string; + apiScheme: string; + tokenApiHost: string; + sdkClientVersion: string; + authDomain?: string; +} + +/** + * Parsed Id Token + * + * TODO(avolkovi): consolidate with parsed_token in implementation + */ +export interface ParsedToken { + 'exp'?: string; + 'sub'?: string; + 'auth_time'?: string; + 'iat'?: string; + 'firebase'?: { + 'sign_in_provider'?: string; + 'sign_in_second_factor'?: string; + }; + [key: string]: string | object | undefined; +} + +/** + * TODO(avolkovi): should we consolidate with Subscribe since we're changing the API anyway? + */ +export type NextOrObserver = NextFn | Observer; + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.AuthSettings + */ +export interface AuthSettings { + appVerificationDisabledForTesting: boolean; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.Auth + */ +export interface Auth { + readonly name: string; + readonly config: Config; + setPersistence(persistence: Persistence): void; + languageCode: string | null; + tenantId: string | null; + readonly settings: AuthSettings; + onAuthStateChanged( + nextOrObserver: NextOrObserver, + error?: ErrorFn, + completed?: CompleteFn + ): Unsubscribe; + onIdTokenChanged( + nextOrObserver: NextOrObserver, + error?: ErrorFn, + completed?: CompleteFn + ): Unsubscribe; + readonly currentUser: User | null; + useDeviceLanguage(): void; + signOut(): Promise; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.Auth#persistence + */ +export interface Persistence { + readonly type: 'SESSION' | 'LOCAL' | 'NONE'; +} + +/** + * Parsed IdToken for use in public API + * + * https://firebase.google.com/docs/reference/js/firebase.auth.IDTokenResult + */ +export interface IdTokenResult { + authTime: string; + expirationTime: string; + issuedAtTime: string; + signInProvider: ProviderId | null; + signInSecondFactor: ProviderId | null; + token: string; + claims: ParsedToken; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.ActionCodeInfo + */ +export interface ActionCodeInfo { + data: { + email?: string | null; + multiFactorInfo?: MultiFactorInfo | null; + previousEmail?: string | null; + }; + operation: Operation; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.ActionCodeInfo#operation_2 + */ +export const enum Operation { + PASSWORD_RESET = 'PASSWORD_RESET', + RECOVER_EMAIL = 'RECOVER_EMAIL', + EMAIL_SIGNIN = 'EMAIL_SIGNIN', + VERIFY_EMAIL = 'VERIFY_EMAIL', + VERIFY_AND_CHANGE_EMAIL = 'VERIFY_AND_CHANGE_EMAIL', + REVERT_SECOND_FACTOR_ADDITION = 'REVERT_SECOND_FACTOR_ADDITION' +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth#actioncodesettings + */ +export interface ActionCodeSettings { + android?: { + installApp?: boolean; + minimumVersion?: string; + packageName: string; + }; + handleCodeInApp?: boolean; + iOS?: { + bundleId: string; + appStoreId: string; + }; + url: string; + dynamicLinkDomain?: string; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.ActionCodeURL + */ +export abstract class ActionCodeURL { + readonly apiKey: string; + readonly code: string; + readonly continueUrl: string | null; + readonly languageCode: string | null; + readonly operation: Operation; + readonly tenantId: string | null; + + static parseLink(link: string): ActionCodeURL | null; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.ApplicationVerifier + */ +export interface ApplicationVerifier { + readonly type: string; + verify(): Promise; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.RecaptchaVerifier + */ +export abstract class RecaptchaVerifier implements ApplicationVerifier { + constructor( + container: any | string, + parameters?: Object | null, + auth?: Auth | null + ); + clear(): void; + render(): Promise; + readonly type: string; + verify(): Promise; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.AuthCredential + */ +export abstract class AuthCredential { + static fromJSON(json: object | string): AuthCredential | null; + + readonly providerId: ProviderId; + readonly signInMethod: SignInMethod; + toJSON(): object; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.OAuthCredential + */ +export abstract class OAuthCredential extends AuthCredential { + static fromJSON(json: object | string): OAuthCredential | null; + + readonly accessToken?: string; + readonly idToken?: string; + readonly secret?: string; +} +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.phoneauthcredential + */ +export abstract class PhoneAuthCredential extends AuthCredential { + static fromJSON(json: object | string): PhoneAuthCredential | null; +} + +/** + * A provider for generating credentials + * + * https://firebase.google.com/docs/reference/js/firebase.auth.AuthProvider + */ +export interface AuthProvider { + readonly providerId: ProviderId; +} + +/** + * A provider for generating email & password and email link credentials + * + * https://firebase.google.com/docs/reference/js/firebase.auth.EmailAuthProvider + */ +export abstract class EmailAuthProvider implements AuthProvider { + private constructor(); + static readonly PROVIDER_ID: ProviderId; + static readonly EMAIL_PASSWORD_SIGN_IN_METHOD: SignInMethod; + static readonly EMAIL_LINK_SIGN_IN_METHOD: SignInMethod; + static credential(email: string, password: string): AuthCredential; + static credentialWithLink( + auth: Auth, + email: string, + emailLink: string + ): AuthCredential; + readonly providerId: ProviderId; +} + +/** + * A provider for generating phone credentials + * + * https://firebase.google.com/docs/reference/js/firebase.auth.PhoneAuthProvider + */ +export class PhoneAuthProvider implements AuthProvider { + static readonly PROVIDER_ID: ProviderId; + static readonly PHONE_SIGN_IN_METHOD: SignInMethod; + static credential( + verificationId: string, + verificationCode: string + ): AuthCredential; + + constructor(auth?: Auth | null); + + readonly providerId: ProviderId; + + verifyPhoneNumber( + phoneInfoOptions: PhoneInfoOptions | string, + applicationVerifier: ApplicationVerifier + ): Promise; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.ConfirmationResult + */ +export interface ConfirmationResult { + readonly verificationId: string; + confirm(verificationCode: string): Promise; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.multifactorassertion + */ +export interface MultiFactorAssertion { + readonly factorId: string; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.multifactorinfo + */ +export interface MultiFactorInfo { + readonly uid: string; + readonly displayName?: string | null; + readonly enrollmentTime: string; + readonly factorId: string; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.multifactorsession + */ +export interface MultiFactorSession {} + +/** + * https://firebase.google.com/docs/reference/js/firebase.user.multifactoruser + */ +export interface MultiFactorUser { + readonly enrolledFactors: MultiFactorInfo[]; + getSession(): Promise; + enroll( + assertion: MultiFactorAssertion, + displayName?: string | null + ): Promise; + unenroll(option: MultiFactorInfo | string): Promise; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth#phoneinfooptions + */ +export interface PhoneInfoOptions { + phoneNumber: string; + // session?: MultiFactorSession; + // multiFactorHint?: MultiFactorInfo; +} + +export interface ReactNativeAsyncStorage { + setItem(key: string, value: string): Promise; + getItem(key: string): Promise; + removeItem(key: string): Promise; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.User + */ +export interface User extends UserInfo { + readonly emailVerified: boolean; + readonly isAnonymous: boolean; + readonly metadata: UserMetadata; + /* readonly multiFactor: MultiFactorUser; */ + readonly providerData: UserInfo[]; + readonly refreshToken: string; + readonly tenantId: string | null; + + delete(): Promise; + getIdToken(forceRefresh?: boolean): Promise; + getIdTokenResult(forceRefresh?: boolean): Promise; + reload(): Promise; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth#usercredential + */ +export interface UserCredential { + user: User; + credential: AuthCredential | null; + operationType: OperationType; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.UserInfo + */ +export interface UserInfo { + readonly displayName: string | null; + readonly email: string | null; + readonly phoneNumber: string | null; + readonly photoURL: string | null; + readonly providerId: ProviderId; + readonly uid: string; +} + +/** + * https://firebase.google.com/docs/reference/js/firebase.auth.UserMetadata + */ +export interface UserMetadata { + readonly creationTime?: string; + readonly lastSignInTime?: string; +} diff --git a/packages-exp/auth-types-exp/package.json b/packages-exp/auth-types-exp/package.json new file mode 100644 index 00000000000..c5ea78bbbd6 --- /dev/null +++ b/packages-exp/auth-types-exp/package.json @@ -0,0 +1,25 @@ +{ + "name": "@firebase/auth-types-exp", + "private": true, + "version": "0.1.0", + "description": "@firebase/auth-exp Types", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "test": "tsc" + }, + "files": [ + "index.d.ts" + ], + "repository": { + "directory": "packages-exp/auth-types-exp", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "devDependencies": { + "typescript": "3.8.3" + } +} diff --git a/packages-exp/auth-types-exp/tsconfig.json b/packages-exp/auth-types-exp/tsconfig.json new file mode 100644 index 00000000000..9a785433d90 --- /dev/null +++ b/packages-exp/auth-types-exp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": [ + "dist/**/*" + ] +} diff --git a/packages-exp/dummy-exp/.eslintrc.js b/packages-exp/dummy-exp/.eslintrc.js new file mode 100644 index 00000000000..ca80aa0f69a --- /dev/null +++ b/packages-exp/dummy-exp/.eslintrc.js @@ -0,0 +1,26 @@ +/** + * @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. + */ + +module.exports = { + extends: '../../config/.eslintrc.js', + parserOptions: { + project: 'tsconfig.json', + // to make vscode-eslint work with monorepo + // https://github.com/typescript-eslint/typescript-eslint/issues/251#issuecomment-463943250 + tsconfigRootDir: __dirname + } +}; diff --git a/packages-exp/dummy-exp/README.md b/packages-exp/dummy-exp/README.md new file mode 100644 index 00000000000..9b72f24c3b5 --- /dev/null +++ b/packages-exp/dummy-exp/README.md @@ -0,0 +1,5 @@ +# @firebase/dummy-exp + +This package coordinates the communication between the different Firebase components + +**This package is not intended for direct usage, and should only be used via the officially supported [firebase](https://www.npmjs.com/package/firebase) package.** diff --git a/packages-exp/dummy-exp/api-extractor.json b/packages-exp/dummy-exp/api-extractor.json new file mode 100644 index 00000000000..8a3c6cb251e --- /dev/null +++ b/packages-exp/dummy-exp/api-extractor.json @@ -0,0 +1,10 @@ +{ + "extends": "../../config/api-extractor.json", + // Point it to your entry point d.ts file. + "mainEntryPointFilePath": "/dist/src/index.d.ts", + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "/dist/.d.ts", + "publicTrimmedFilePath": "/dist/-public.d.ts" + } +} \ No newline at end of file diff --git a/packages-exp/dummy-exp/karma.conf.js b/packages-exp/dummy-exp/karma.conf.js new file mode 100644 index 00000000000..fe4ace4252f --- /dev/null +++ b/packages-exp/dummy-exp/karma.conf.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019 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. + */ + +const karmaBase = require('../../config/karma.base'); + +const files = ['src/**/*.test.ts']; + +module.exports = function(config) { + const karmaConfig = Object.assign({}, karmaBase, { + // files to load into karma + files: files, + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'] + }); + + config.set(karmaConfig); +}; + +module.exports.files = files; diff --git a/packages-exp/dummy-exp/package.json b/packages-exp/dummy-exp/package.json new file mode 100644 index 00000000000..f5c079ac47c --- /dev/null +++ b/packages-exp/dummy-exp/package.json @@ -0,0 +1,63 @@ +{ + "name": "@firebase/dummy-exp", + "version": "0.0.800", + "private": true, + "description": "FirebaseDummy", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.cjs.js", + "browser": "dist/index.esm5.js", + "module": "dist/index.esm5.js", + "esm2017": "dist/index.esm2017.js", + "files": [ + "dist" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c", + "build:release": "rollup -c rollup.config.release.js && yarn api-report && yarn typings:public", + "build:deps": "lerna run --scope @firebase/dummy-exp --include-dependencies build", + "dev": "rollup -c -w", + "test": "yarn type-check && run-p lint test:browser test:node", + "test:ci": "node ../../scripts/run_tests_in_ci.js", + "test:browser": "karma start --single-run", + "test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.ts --config ../../config/mocharc.node.js", + "type-check": "tsc -p . --noEmit", + "prepare": "rollup -c rollup.config.release.js", + "api-report": "api-extractor run --local --verbose", + "predoc": "node ../../scripts/exp/remove-exp.js temp", + "doc": "api-documenter markdown --input temp --output docs", + "build:doc": "yarn build && yarn doc", + "typings:public": "node ./use_public_typings.js --public", + "typings:internal": "node ./use_public_typings.js" + }, + "dependencies": { + "@firebase/logger": "0.2.5", + "tslib": "1.11.1", + "typescript": "3.8.3" + }, + "license": "Apache-2.0", + "devDependencies": { + "@rollup/plugin-alias": "3.1.1", + "rollup": "1.32.1", + "rollup-plugin-json": "4.0.0", + "rollup-plugin-replace": "2.2.0", + "rollup-plugin-typescript2": "0.27.0", + "typescript": "3.8.3" + }, + "repository": { + "directory": "packages-exp/dummy-exp", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "./dist/packages-exp/dummy-exp/src/index.d.ts", + "nyc": { + "extension": [ + ".ts" + ], + "reportDir": "./coverage/node" + } +} \ No newline at end of file diff --git a/packages-exp/dummy-exp/rollup.config.js b/packages-exp/dummy-exp/rollup.config.js new file mode 100644 index 00000000000..2ef545bf86d --- /dev/null +++ b/packages-exp/dummy-exp/rollup.config.js @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2019 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 typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; +import json from 'rollup-plugin-json'; +import pkg from './package.json'; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }), + json() +]; + +const es5Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: [{ file: pkg.browser, format: 'es', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + }, + /** + * Node.js Build + */ + { + input: 'src/index.ts', + output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }), + json({ + preferConst: true + }) +]; + +const es2017Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: { + file: pkg.esm2017, + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/dummy-exp/rollup.config.release.js b/packages-exp/dummy-exp/rollup.config.release.js new file mode 100644 index 00000000000..3c7cc0c9ebc --- /dev/null +++ b/packages-exp/dummy-exp/rollup.config.release.js @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2019 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 typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; +import json from 'rollup-plugin-json'; +import pkg from './package.json'; +import { importPathTransformer } from '../../scripts/exp/ts-transform-import-path'; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript, + clean: true, + transformers: [importPathTransformer] + }), + json() +]; + +const es5Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: [{ file: pkg.browser, format: 'es', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + treeshake: { + moduleSideEffects: false + } + }, + /** + * Node.js Build + */ + { + input: 'src/index.ts', + output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + treeshake: { + moduleSideEffects: false + } + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + }, + clean: true, + transformers: [importPathTransformer] + }), + json({ + preferConst: true + }) +]; + +const es2017Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: { + file: pkg.esm2017, + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + treeshake: { + moduleSideEffects: false + } + } +]; + +export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/dummy-exp/src/bar.ts b/packages-exp/dummy-exp/src/bar.ts new file mode 100644 index 00000000000..b881e0fd38d --- /dev/null +++ b/packages-exp/dummy-exp/src/bar.ts @@ -0,0 +1,30 @@ +/** + * @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 { version } from '../../../packages/firebase/package.json'; +import { LogLevel } from '@firebase/logger'; +export const SDK_VERSION = version; +export function bar(): LogLevel { + return bar1(); +} + +export function bar1(): LogLevel { + return LogLevel.DEBUG; +} + +export function bar2(): string { + return bar1().toLocaleString() + bar(); +} diff --git a/packages-exp/dummy-exp/src/far.ts b/packages-exp/dummy-exp/src/far.ts new file mode 100644 index 00000000000..b46f857e12c --- /dev/null +++ b/packages-exp/dummy-exp/src/far.ts @@ -0,0 +1,30 @@ +/** + * @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 function far(version: string): string { + console.log(version); + return version; +} + +export function far1(version: string): string { + console.log(version); + return version; +} +export function far3(version: string): string { + console.log(version); + return version; +} diff --git a/packages-exp/dummy-exp/src/foo.ts b/packages-exp/dummy-exp/src/foo.ts new file mode 100644 index 00000000000..7a5b9866e1c --- /dev/null +++ b/packages-exp/dummy-exp/src/foo.ts @@ -0,0 +1,40 @@ +/** + * @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 const fooo: string = 'foooo'; +export const f2: string = 'foo2'; +export const f1: string = 'foo1'; + +export function foo(): string { + return foo1(); +} + +export function foo1(): string { + return f1; +} +export function foo2(): string { + return f1 + f2; +} +export class Apple {} + +export enum BUG { + DEBUG = 0, + VERBOSE = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + SILENT = 5 +} diff --git a/packages-exp/dummy-exp/src/index.ts b/packages-exp/dummy-exp/src/index.ts new file mode 100644 index 00000000000..a551ecc0919 --- /dev/null +++ b/packages-exp/dummy-exp/src/index.ts @@ -0,0 +1,61 @@ +/** + * @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 * from './bar'; + +export * from './foo'; +// import { SDK_VERSION } from './bar'; +// import { far } from './far'; +export { far1, far, far3 } from './far'; +export const VAR = 'variable'; +export let var2: string; +export let var3 = 'var3'; +export class Student {} +import { LogLevel } from '@firebase/logger'; +var3 = 'var3Changed'; + +export function boo(): LogLevel { + return LogLevel.DEBUG; +} + +export enum LogLevel1 { + DEBUG = 0, + VERBOSE = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + SILENT = 5 +} + +export interface LogCallbackParams { + level: LogLevel1; + message: string; + args: unknown[]; + type: string; +} + +export type LogLevel2 = + | 'debug' + | 'error' + | 'silent' + | 'warn' + | 'info' + | 'verbose'; + +// far(SDK_VERSION); + +export { LogLevel } from '@firebase/logger'; diff --git a/packages-exp/dummy-exp/tsconfig.json b/packages-exp/dummy-exp/tsconfig.json new file mode 100644 index 00000000000..735ea623511 --- /dev/null +++ b/packages-exp/dummy-exp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "downlevelIteration": true + }, + "exclude": [ + "dist/**/*" + ] +} \ No newline at end of file diff --git a/packages-exp/dummy-exp/use_typings.js b/packages-exp/dummy-exp/use_typings.js new file mode 100644 index 00000000000..a1830bf2840 --- /dev/null +++ b/packages-exp/dummy-exp/use_typings.js @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * @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. + */ + +const { writeFileSync } = require('fs'); +const { argv } = require('yargs'); + +const path = require('path'); +const packageJsonPath = path.resolve(__dirname, './package.json'); + +// point typings field to the public d.ts file in package.json +const TYPINGS_PATH = argv.public + ? './dist/dummy-exp-public.d.ts' + : './dist/dummy-exp.d.ts'; +console.log( + `Updating the packages-exp/dummy-exp typings field to the ${ + argv.public ? 'public' : 'internal' + } d.ts file ${TYPINGS_PATH}` +); +const packageJson = require(packageJsonPath); +packageJson.typings = TYPINGS_PATH; + +writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, { + encoding: 'utf-8' +}); diff --git a/packages-exp/firebase-exp/package.json b/packages-exp/firebase-exp/package.json index b2addcd6678..f5c4d34b842 100644 --- a/packages-exp/firebase-exp/package.json +++ b/packages-exp/firebase-exp/package.json @@ -35,7 +35,11 @@ "test:ci": "echo 'No test suite for firebase wrapper'" }, "dependencies": { - "@firebase/app-exp": "0.0.800" + "@firebase/app-exp": "0.0.800", + "@firebase/dummy-exp": "0.0.800", + "@firebase/functions-exp": "0.0.800", + "@firebase/auth-exp": "0.1.0", + "@firebase/auth-compat-exp": "0.1.0" }, "devDependencies": { "rollup": "1.32.1", @@ -52,6 +56,9 @@ "typescript": "3.7.5" }, "components": [ - "app" + "app", + "dummy", + "functions", + "auth" ] -} +} \ No newline at end of file diff --git a/packages-exp/functions-exp/package.json b/packages-exp/functions-exp/package.json index 4464690c0ca..86eb65ffec5 100644 --- a/packages-exp/functions-exp/package.json +++ b/packages-exp/functions-exp/package.json @@ -49,7 +49,7 @@ "bugs": { "url": "https://github.com/firebase/firebase-js-sdk/issues" }, - "typings": "dist/index.d.ts", + "typings": "dist/src/index.d.ts", "dependencies": { "@firebase/component": "0.1.14", "@firebase/functions-types-exp": "0.0.800", diff --git a/packages/analytics/index.ts b/packages/analytics/index.ts index a66f3332117..c7ce3a6d734 100644 --- a/packages/analytics/index.ts +++ b/packages/analytics/index.ts @@ -45,6 +45,7 @@ declare global { * Type constant for Firebase Analytics. */ const ANALYTICS_TYPE = 'analytics'; + export function registerAnalytics(instance: _FirebaseNamespace): void { instance.INTERNAL.registerComponent( new Component( @@ -55,7 +56,6 @@ export function registerAnalytics(instance: _FirebaseNamespace): void { const installations = container .getProvider('installations') .getImmediate(); - return factory(app, installations); }, ComponentType.PUBLIC diff --git a/packages/firestore/exp/test/deps/dependencies.json b/packages/firestore/exp/test/deps/dependencies.json index e348f877ca0..84536f1eb64 100644 --- a/packages/firestore/exp/test/deps/dependencies.json +++ b/packages/firestore/exp/test/deps/dependencies.json @@ -20,4 +20,4 @@ }, "sizeInBytes": 67 } -} +} \ No newline at end of file diff --git a/packages/firestore/package.json b/packages/firestore/package.json index 8165709d3ee..55d691ceb77 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -21,7 +21,7 @@ "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", "prettier": "prettier --write '*.ts' '*.js' 'exp/**/*.ts' 'src/**/*.js' 'test/**/*.js' 'src/**/*.ts' 'test/**/*.ts'", "pregendeps:exp": "yarn build:exp", - "gendeps:exp": "../../scripts/exp/extract-deps.sh --types ./exp/index.d.ts --bundle ./dist/exp/index.js --output ./exp/test/deps/dependencies.json", + "gendeps:exp": "../../scripts/exp/extract-deps.sh --types ./dist/exp/packages/firestore/exp/src/api/foobar.d.ts --bundle ./dist/exp/foobar.js --output ./exp/test/deps/dependencies.json", "pretest:exp": "yarn build:exp", "pregendeps:lite": "yarn build:lite", "gendeps:lite": "../../scripts/exp/extract-deps.sh --types ./lite/index.d.ts --bundle ./dist/lite/index.node.esm2017.js --output ./lite/test/dependencies.json", diff --git a/packages/firestore/rollup.config.exp.js b/packages/firestore/rollup.config.exp.js index 912f2a943a1..f4cdd97a030 100644 --- a/packages/firestore/rollup.config.exp.js +++ b/packages/firestore/rollup.config.exp.js @@ -46,6 +46,19 @@ const nodeBuilds = [ treeshake: { tryCatchDeoptimization: false } + }, + + { + input: './exp/src/api/foobar.ts', + output: { + file: './dist/exp/foobar.js', + format: 'es' + }, + plugins: defaultPlugins, + external: resolveNodeExterns, + treeshake: { + tryCatchDeoptimization: false + } } ]; diff --git a/scripts/exp/extract-deps.helpers.ts b/scripts/exp/extract-deps.helpers.ts index 4df429b8102..61f4b760639 100644 --- a/scripts/exp/extract-deps.helpers.ts +++ b/scripts/exp/extract-deps.helpers.ts @@ -48,6 +48,7 @@ export async function extractDependencies( exportName, jsBundle ); + console.log(dependencies); return dependencies; } @@ -61,11 +62,17 @@ export async function extractDependenciesAndSize( ): Promise { const input = tmp.fileSync().name + '.js'; const output = tmp.fileSync().name + '.js'; + console.log(input); + console.log(output); + // jsBundle : /Users/xuechunhou/Desktop/Google/firebase-js-sdk/packages/firestore/dist/exp/index.js // JavaScript content that exports a single API from the bundle + //beforeCOntent: export { Blob } from '/Users/xuechunhou/Desktop/Google/firebase-js-sdk/packages/firestore/dist/exp/index.js'; const beforeContent = `export { ${exportName} } from '${path.resolve( jsBundle )}';`; + console.log('beforeContent\n'); + console.log(beforeContent); fs.writeFileSync(input, beforeContent); // Run Rollup on the JavaScript above to produce a tree-shaken build @@ -76,6 +83,7 @@ export async function extractDependenciesAndSize( await bundle.write({ file: output, format: 'es' }); const dependencies = extractDeclarations(output); + console.log(dependencies); // Extract size of minified build const afterContent = fs.readFileSync(output, 'utf-8'); @@ -89,7 +97,7 @@ export async function extractDependenciesAndSize( fs.unlinkSync(input); fs.unlinkSync(output); - + console.log(code); return { dependencies, sizeInBytes: Buffer.byteLength(code!, 'utf-8') }; } @@ -99,6 +107,7 @@ export async function extractDependenciesAndSize( */ export function extractDeclarations(jsFile: string): MemberList { const program = ts.createProgram([jsFile], { allowJs: true }); + const sourceFile = program.getSourceFile(jsFile); if (!sourceFile) { throw new Error('Failed to parse file: ' + jsFile); diff --git a/scripts/exp/extract-deps.sh b/scripts/exp/extract-deps.sh index 410fe120f15..857406701b1 100755 --- a/scripts/exp/extract-deps.sh +++ b/scripts/exp/extract-deps.sh @@ -27,5 +27,8 @@ GENERATE_DEPS_JS="$DIR/extract-deps.ts" export TS_NODE_CACHE=NO export TS_NODE_COMPILER_OPTIONS='{"module":"commonjs"}' export TS_NODE_PROJECT="$DIR/../../config/tsconfig.base.json" - +echo $TSNODE +echo $GENERATE_DEPS_JS +#Users/xuechunhou/Desktop/Google/firebase-js-sdk/node_modules/.bin/ts-node +#/Users/xuechunhou/Desktop/Google/firebase-js-sdk/scripts/exp/extract-deps.ts $TSNODE $GENERATE_DEPS_JS "$@" diff --git a/scripts/exp/modular-export-binary-size-analysis/.eslintrc.js b/scripts/exp/modular-export-binary-size-analysis/.eslintrc.js new file mode 100644 index 00000000000..ca80aa0f69a --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/.eslintrc.js @@ -0,0 +1,26 @@ +/** + * @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. + */ + +module.exports = { + extends: '../../config/.eslintrc.js', + parserOptions: { + project: 'tsconfig.json', + // to make vscode-eslint work with monorepo + // https://github.com/typescript-eslint/typescript-eslint/issues/251#issuecomment-463943250 + tsconfigRootDir: __dirname + } +}; diff --git a/scripts/exp/modular-export-binary-size-analysis/analysis-helper.ts b/scripts/exp/modular-export-binary-size-analysis/analysis-helper.ts new file mode 100644 index 00000000000..09d065d1d76 --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/analysis-helper.ts @@ -0,0 +1,311 @@ +/** + * @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 * as tmp from 'tmp'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as rollup from 'rollup'; +import * as terser from 'terser'; +import * as ts from 'typescript'; +import { TYPINGS } from './analysis'; +import { request } from 'express'; + +import resolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; + +/** Contains a list of members by type. */ +export type MemberList = { + classes: string[]; + functions: string[]; + variables: string[]; + enums: string[]; +}; +/** Contains the dependencies and the size of their code for a single export. */ +export type ExportData = { dependencies: MemberList; sizeInBytes: number }; + +/** + * This functions builds a simple JS app that only depends on the provided + * export. It then uses Rollup to gather all top-level classes and functions + * that that the export depends on. + * + * @param exportName The name of the export to verify + * @param jsBundle The file name of the source bundle that contains the export + * @return A list of dependencies for the given export + */ +export async function extractDependencies( + exportName: string, + jsBundle: string, + map: Map +): Promise { + const { dependencies } = await extractDependenciesAndSize( + exportName, + jsBundle, + map + ); + return dependencies; +} + +/** + * Helper for extractDependencies that extracts the dependencies and the size + * of the minified build. + */ +export async function extractDependenciesAndSize( + exportName: string, + jsBundle: string, + map: Map +): Promise { + const input = tmp.fileSync().name + '.js'; + const output = tmp.fileSync().name + '.js'; + + const beforeContent = `export { ${exportName} } from '${path.resolve( + jsBundle + )}';`; + //console.log(beforeContent); + fs.writeFileSync(input, beforeContent); + + // Run Rollup on the JavaScript above to produce a tree-shaken build + const bundle = await rollup.rollup({ + input, + plugins: [ + resolve({ + mainFields: ['esm2017', 'module', 'main'] + }), + commonjs() + ] + }); + await bundle.write({ file: output, format: 'es' }); + + const dependencies = extractDeclarations(output, map); + + // Extract size of minified build + const afterContent = fs.readFileSync(output, 'utf-8'); + const { code } = terser.minify(afterContent, { + output: { + comments: false + }, + mangle: false, + compress: false + }); + + fs.unlinkSync(input); + fs.unlinkSync(output); + return { dependencies, sizeInBytes: Buffer.byteLength(code!, 'utf-8') }; +} + +/** + * Extracts all function, class and variable declarations using the TypeScript + * compiler API. + */ +export function extractDeclarations( + jsFile: string, + map?: Map +): MemberList { + const program = ts.createProgram([jsFile], { allowJs: true }); + const checker = program.getTypeChecker(); + + const sourceFile = program.getSourceFile(jsFile); + if (!sourceFile) { + throw new Error('Failed to parse file: ' + jsFile); + } + + let declarations: MemberList = { + functions: [], + classes: [], + variables: [], + enums: [] + }; + + ts.forEachChild(sourceFile, node => { + if (ts.isFunctionDeclaration(node)) { + declarations.functions.push(node.name!.text); + } else if (ts.isClassDeclaration(node)) { + declarations.classes.push(node.name!.text); + } else if (ts.isVariableDeclaration(node)) { + declarations.variables.push(node.name!.getText()); + } else if (ts.isEnumDeclaration(node)) { + declarations.enums.push(node.name.escapedText.toString()); + } else if (ts.isVariableStatement(node)) { + const variableDeclarations = node.declarationList.declarations; + variableDeclarations.forEach(variableDeclaration => { + if (ts.isIdentifier(variableDeclaration.name)) { + declarations.variables.push( + (variableDeclaration.name as ts.Identifier).getText(sourceFile) + ); + } + // TODO: variableDeclaration.name is an union type (Identifier | BindingPattern) + // need Identifier type, not sure in what case BindingPattern type is for. + else { + console.log( + 'this VariableDeclaration.name object is of BindingPattern type !' + ); + } + }); + } else if (ts.isExportDeclaration(node)) { + if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + const symbol = checker.getSymbolAtLocation(node.moduleSpecifier); + const reExportFullPath = symbol.valueDeclaration.getSourceFile() + .fileName; + const reExportsMember = extractDeclarations(reExportFullPath); + + // named exports + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + const actualExports: string[] = []; + node.exportClause.elements.forEach(exportSpecifier => { + const reExportedSymbol: string = extractRealSymbolName( + exportSpecifier + ); + // if export is renamed, replace with new name + if (isExportRenamed(exportSpecifier)) { + actualExports.push(exportSpecifier.name.escapedText.toString()); + replaceAll( + reExportsMember, + reExportedSymbol, + exportSpecifier.name.escapedText.toString() + ); + } else { + actualExports.push(reExportedSymbol); + } + }); + filterAllBy(reExportsMember, actualExports); + } + // concatename reExport MemberList with MemberList of the dts file + declarations.functions.push(...reExportsMember.functions); + declarations.variables.push(...reExportsMember.variables); + declarations.classes.push(...reExportsMember.classes); + declarations.enums.push(...reExportsMember.enums); + } + } + }); + + if (map) { + declarations = mapSymbolToType(map, declarations); + declarations = dedup(declarations); + } + + // Sort to ensure stable output + declarations.functions.sort(); + declarations.classes.sort(); + declarations.variables.sort(); + declarations.enums.sort(); + + return declarations; +} + +function dedup(memberList: MemberList): MemberList { + const classesSet: Set = new Set(memberList.classes); + memberList.classes = Array.from(classesSet); + + const functionsSet: Set = new Set(memberList.functions); + memberList.functions = Array.from(functionsSet); + + const enumsSet: Set = new Set(memberList.enums); + memberList.enums = Array.from(enumsSet); + + const variablesSet: Set = new Set(memberList.variables); + memberList.variables = Array.from(variablesSet); + + return memberList; +} +function mapSymbolToType( + map: Map, + memberList: MemberList +): MemberList { + const newMemberList: MemberList = { + functions: [], + classes: [], + variables: [], + enums: [] + }; + memberList.classes.forEach(element => { + if (map.has(element)) { + newMemberList[map.get(element)].push(element); + } else { + newMemberList.classes.push(element); + } + }); + memberList.variables.forEach(element => { + if (map.has(element)) { + newMemberList[map.get(element)].push(element); + } else { + newMemberList.variables.push(element); + } + }); + memberList.enums.forEach(element => { + if (map.has(element)) { + newMemberList[map.get(element)].push(element); + } else { + newMemberList.enums.push(element); + } + }); + memberList.functions.forEach(element => { + if (map.has(element)) { + newMemberList[map.get(element)].push(element); + } else { + newMemberList.functions.push(element); + } + }); + return newMemberList; +} +function isReExported(symbol: string, reExportedSymbols: string[]): boolean { + return reExportedSymbols.includes(symbol); +} + +function extractRealSymbolName(exportSpecifier: ts.ExportSpecifier): string { + // if property name is not null -> export is renamed + if (exportSpecifier.propertyName) { + return exportSpecifier.propertyName.escapedText.toString(); + } + + return exportSpecifier.name.escapedText.toString(); +} +function filterAllBy(memberList: MemberList, keep: string[]) { + memberList.functions = memberList.functions.filter(each => + isReExported(each, keep) + ); + memberList.variables = memberList.variables.filter(each => + isReExported(each, keep) + ); + memberList.classes = memberList.classes.filter(each => + isReExported(each, keep) + ); + memberList.enums = memberList.enums.filter(each => isReExported(each, keep)); +} + +function replaceAll(memberList: MemberList, original: string, current: string) { + memberList.classes = replaceWith(memberList.classes, original, current); + memberList.variables = replaceWith(memberList.variables, original, current); + memberList.functions = replaceWith(memberList.functions, original, current); + memberList.enums = replaceWith(memberList.enums, original, current); +} +function replaceWith( + arr: string[], + original: string, + current: string +): string[] { + const rv: string[] = []; + for (let each of arr) { + if (each.localeCompare(original) == 0) { + rv.push(current); + } else { + rv.push(each); + } + } + return rv; +} +function isExportRenamed(exportSpecifier: ts.ExportSpecifier): boolean { + return exportSpecifier.propertyName != null; +} diff --git a/scripts/exp/modular-export-binary-size-analysis/analysis.ts b/scripts/exp/modular-export-binary-size-analysis/analysis.ts new file mode 100644 index 00000000000..eb80f8603db --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/analysis.ts @@ -0,0 +1,136 @@ +/** + * @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 * as fs from 'fs'; +import { resolve } from 'path'; +import { + extractDependenciesAndSize, + extractDeclarations, + MemberList, + ExportData +} from './analysis-helper'; +import { mapWorkspaceToPackages } from '../../release/utils/workspace'; +import { projectRoot } from '../../utils'; +import { getTsBuildInfoEmitOutputFilePath } from 'typescript'; + +export const TYPINGS: string = 'typings'; +const BUNDLE: string = 'esm2017'; +const OUTPUTDIR: string = './dependencies'; +const DUMMYMODULE: string = '@firebase/dummy-exp'; +const FUNCTIONMODULE: string = '@firebase/functions-exp'; +const APPMODULE: string = '@firebase/app-exp'; +const AUTHMODULE: string = '@firebase/auth-exp'; + +function collectBinarySize(path: string) { + const packageJsonPath = `${path}/package.json`; + if (!fs.existsSync(packageJsonPath)) { + return; + } + + const packageJson = require(packageJsonPath); + + // to exclude -types modules + if (packageJson[TYPINGS] && packageJson.name == AUTHMODULE) { + const dtsFile = `${path}/${packageJson[TYPINGS]}`; + // extract all export declarations + + const publicApi = extractDeclarations(resolve(dtsFile)); + + if (!packageJson[BUNDLE]) { + console.log('This module does not have bundle file!'); + return; + } + const map: Map = buildMap(publicApi); + //calculate binary size for every export and build a json report + buildJson(publicApi, `${path}/${packageJson[BUNDLE]}`, map).then(json => { + console.log(json); + //fs.writeFileSync(resolve(`${OUTPUTDIR}/${packageJson.name}/dependencies.json`), json); + }); + } +} +function buildMap(api: MemberList): Map { + const map: Map = new Map(); + api.functions.forEach(element => { + map.set(element, 'functions'); + }); + api.classes.forEach(element => { + map.set(element, 'classes'); + }); + api.enums.forEach(element => { + map.set(element, 'enums'); + }); + api.variables.forEach(element => { + map.set(element, 'variables'); + }); + return map; +} +function traverseDirs( + moduleLocation: string, + executor, + level: number, + levelLimit: number +) { + if (level > levelLimit) { + return; + } + + executor(moduleLocation); + + for (const name of fs.readdirSync(moduleLocation)) { + const p = `${moduleLocation}/${name}`; + + if (fs.lstatSync(p).isDirectory()) { + traverseDirs(p, executor, level + 1, levelLimit); + } + } +} + +async function buildJson( + publicApi: MemberList, + jsFile: string, + map: Map +): Promise { + const result: { [key: string]: ExportData } = {}; + for (const exp of publicApi.classes) { + result[exp] = await extractDependenciesAndSize(exp, jsFile, map); + } + for (const exp of publicApi.functions) { + result[exp] = await extractDependenciesAndSize(exp, jsFile, map); + } + //console.log(publicApi.variables); + for (const exp of publicApi.variables) { + result[exp] = await extractDependenciesAndSize(exp, jsFile, map); + } + + for (const exp of publicApi.enums) { + result[exp] = await extractDependenciesAndSize(exp, jsFile, map); + } + return JSON.stringify(result, null, 4); +} + +async function main() { + // retrieve All Modules Name + const allModulesLocation = await mapWorkspaceToPackages([ + `${projectRoot}/packages-exp/*` + ]); + for (const moduleLocation of allModulesLocation) { + // we traverse the dir in order to include binaries for submodules, e.g. @firebase/firestore/memory + // Currently we only traverse 1 level deep because we don't have any submodule deeper than that. + traverseDirs(moduleLocation, collectBinarySize, 0, 1); + } +} +main(); diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/api-extractor.json b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/api-extractor.json new file mode 100644 index 00000000000..7f7f9c9a546 --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/api-extractor.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../../config/api-extractor.json", + // Point it to your entry point d.ts file. + "mainEntryPointFilePath": "/dist/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/index.d.ts", + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "/dist/.d.ts", + "publicTrimmedFilePath": "/dist/-public.d.ts" + } +} \ No newline at end of file diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/karma.conf.js b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/karma.conf.js new file mode 100644 index 00000000000..fe4ace4252f --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/karma.conf.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019 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. + */ + +const karmaBase = require('../../config/karma.base'); + +const files = ['src/**/*.test.ts']; + +module.exports = function(config) { + const karmaConfig = Object.assign({}, karmaBase, { + // files to load into karma + files: files, + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'] + }); + + config.set(karmaConfig); +}; + +module.exports.files = files; diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/package.json b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/package.json new file mode 100644 index 00000000000..81aae7f4a45 --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/package.json @@ -0,0 +1,63 @@ +{ + "name": "@firebase/test-exp", + "version": "0.0.800", + "private": true, + "description": "TestSizeAnalysis", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.cjs.js", + "browser": "dist/index.esm5.js", + "module": "dist/index.esm5.js", + "esm2017": "dist/index.esm2017.js", + "files": [ + "dist" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c", + "build:release": "rollup -c rollup.config.release.js && yarn api-report && yarn typings:public", + "build:deps": "lerna run --scope @firebase/test-exp --include-dependencies build", + "dev": "rollup -c -w", + "test": "yarn type-check && run-p lint test:node", + "test:ci": "node ../../scripts/run_tests_in_ci.js", + "test:browser": "karma start --single-run", + "test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.ts --config ../../../../../config/mocharc.node.js", + "type-check": "tsc -p . --noEmit", + "prepare": "rollup -c rollup.config.release.js", + "api-report": "api-extractor run --local --verbose", + "predoc": "node ../../scripts/exp/remove-exp.js temp", + "doc": "api-documenter markdown --input temp --output docs", + "build:doc": "yarn build && yarn doc", + "typings:public": "node ./use_public_typings.js --public", + "typings:internal": "node ./use_public_typings.js" + }, + "dependencies": { + "@firebase/logger": "0.2.5", + "tslib": "1.11.1", + "typescript": "3.8.3" + }, + "license": "Apache-2.0", + "devDependencies": { + "@rollup/plugin-alias": "3.1.1", + "rollup": "1.32.1", + "rollup-plugin-json": "4.0.0", + "rollup-plugin-replace": "2.2.0", + "rollup-plugin-typescript2": "0.27.0", + "typescript": "3.8.3" + }, + "repository": { + "directory": "packages-exp/test-exp", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "./dist/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/index.d.ts", + "nyc": { + "extension": [ + ".ts" + ], + "reportDir": "./coverage/node" + } +} \ No newline at end of file diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/rollup.config.js b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/rollup.config.js new file mode 100644 index 00000000000..2ef545bf86d --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/rollup.config.js @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2019 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 typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; +import json from 'rollup-plugin-json'; +import pkg from './package.json'; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }), + json() +]; + +const es5Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: [{ file: pkg.browser, format: 'es', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + }, + /** + * Node.js Build + */ + { + input: 'src/index.ts', + output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }), + json({ + preferConst: true + }) +]; + +const es2017Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: { + file: pkg.esm2017, + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +export default [...es5Builds, ...es2017Builds]; diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/rollup.config.release.js b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/rollup.config.release.js new file mode 100644 index 00000000000..014cfe7dbeb --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/rollup.config.release.js @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2019 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 typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; +import json from 'rollup-plugin-json'; +import pkg from './package.json'; +import { importPathTransformer } from '../../../ts-transform-import-path'; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript, + clean: true, + transformers: [importPathTransformer] + }), + json() +]; + +const es5Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: [{ file: pkg.browser, format: 'es', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + treeshake: { + moduleSideEffects: false + } + }, + /** + * Node.js Build + */ + { + input: 'src/index.ts', + output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + treeshake: { + moduleSideEffects: false + } + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + }, + clean: true, + transformers: [importPathTransformer] + }), + json({ + preferConst: true + }) +]; + +const es2017Builds = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: { + file: pkg.esm2017, + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)), + treeshake: { + moduleSideEffects: false + } + } +]; + +export default [...es5Builds, ...es2017Builds]; diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/bar.ts b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/bar.ts new file mode 100644 index 00000000000..4ccab9a4541 --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/bar.ts @@ -0,0 +1,61 @@ +/** + * @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 { version } from '../../../../../../packages/firebase/package.json'; +import { LogLevel } from '@firebase/logger'; + +export let basicVarDeclarationExportBar: string; +export const basicVarStatementExportBar = 'basicVarStatementExportBar'; +export const reExportVarStatmentExportBar = version; + +export enum BasicEnumExportBar { + DEBUG = 0, + VERBOSE = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + SILENT = 5 +} + +export class BasicClassExportBar {} + +export function basicFuncExportNoDependenciesBar(): string { + return 'basicFuncExportNoDependenciesBar'; +} +export function basicFuncExportVarDependenciesBar(): string { + return basicVarStatementExportBar; +} + +function d1(): string { + return 'd1'; +} +function d2(): string { + return 'd2'; +} +function d3(): string { + return d1() + d2(); +} +export function basicFuncExportFuncDependenciesBar(): string { + return d3(); +} + +export function basicFuncExportEnumDependenciesBar(): BasicEnumExportBar { + return BasicEnumExportBar.DEBUG; +} + +export function basicFuncExternalDependenciesBar(): LogLevel { + return LogLevel.ERROR; +} diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/far.ts b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/far.ts new file mode 100644 index 00000000000..0f7a5d7b12a --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/far.ts @@ -0,0 +1,61 @@ +/** + * @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 { name } from '../../../../../../packages/firebase/package.json'; +import { LogLevel } from '@firebase/logger'; + +export let basicVarDeclarationExportFar: string; +export const basicVarStatementExportFar = 'basicVarStatementExportFar'; +export const reExportVarStatmentExportFar = name; + +export enum BasicEnumExportFar { + DEBUG = 0, + VERBOSE = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + SILENT = 5 +} + +export class BasicClassExportFar {} + +export function basicFuncExportNoDependenciesFar(): string { + return 'basicFuncExportNoDependenciesFar'; +} +export function basicFuncExportVarDependenciesFar(): string { + return basicVarStatementExportFar; +} + +function d1(): string { + return 'd1'; +} +function d2(): string { + return 'd2'; +} +function d3(): string { + return d1() + d2(); +} +export function basicFuncExportFuncDependenciesFar(): string { + return d3(); +} + +export function basicFuncExportEnumDependenciesFar(): BasicEnumExportFar { + return BasicEnumExportFar.DEBUG; +} + +export function basicFuncExternalDependenciesFar(): LogLevel { + return LogLevel.ERROR; +} diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/index.ts b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/index.ts new file mode 100644 index 00000000000..6d42d0979e8 --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/index.ts @@ -0,0 +1,79 @@ +/** + * @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 { LogLevel } from '@firebase/logger'; +import { license } from '../../../../../../packages/firebase/package.json'; +// wildcard export +export * from './bar'; +// named export +export { + BasicEnumExportFar, + BasicClassExportFar, + basicFuncExportNoDependenciesFar, + basicFuncExportVarDependenciesFar, + basicFuncExportFuncDependenciesFar, + basicFuncExportEnumDependenciesFar, + basicFuncExternalDependenciesFar, + basicVarDeclarationExportFar, + basicVarStatementExportFar, + reExportVarStatmentExportFar +} from './far'; + +export let basicVarDeclarationExport: string; +export const basicVarStatementExport = 'basicVarStatementExport'; +export const reExportVarStatmentExport = license; + +export enum BasicEnumExport { + DEBUG = 0, + VERBOSE = 1, + INFO = 2, + WARN = 3, + ERROR = 4, + SILENT = 5 +} + +export class basicClassExport {} + +export function basicFuncExportNoDependencies(): string { + return 'basicFuncExportNoDependencies'; +} +export function basicFuncExportVarDependencies(): string { + return basicVarStatementExport; +} + +function d1(): string { + return 'd1'; +} +function d2(): string { + return 'd2'; +} +function d3(): string { + return d1() + d2(); +} +export function basicFuncExportFuncDependencies(): string { + return d3(); +} + +export function basicFuncExportEnumDependencies(): BasicEnumExport { + return BasicEnumExport.DEBUG; +} + +export function basicFuncExternalDependencies(): LogLevel { + return LogLevel.WARN; +} + +// re-export from firebase external module +export { LogLevel } from '@firebase/logger'; diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/size-analysis.test.ts b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/size-analysis.test.ts new file mode 100644 index 00000000000..fd2963c7585 --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/src/size-analysis.test.ts @@ -0,0 +1,32 @@ +/** + * @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 } from 'chai'; + +import { extractDeclarations } from '../../../analysis-helper'; +import { retrieveTestModuleDtsFile } from '../test-utils/setup'; + +describe('extractDeclarations', () => { + before(() => { + const testModuleDtsFile = retrieveTestModuleDtsFile(); + console.log(testModuleDtsFile); + const extractedDeclarations = extractDeclarations(testModuleDtsFile); + }); + it('test basic variable extractions', () => {}); + + it('test re-exported variable extractions', () => {}); +}); diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/test-utils/setup.ts b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/test-utils/setup.ts new file mode 100644 index 00000000000..a741cc70a0c --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/test-utils/setup.ts @@ -0,0 +1,26 @@ +/** + * @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 { projectRoot } from '../../../../../release/utils'; + +export function retrieveTestModuleDtsFile(): string { + const moduleLocation = `${projectRoot}/scripts/exp/modular-export-binary-size-analysis/test/test-exp`; + const packageJson = require(`${moduleLocation}/package.json`); + const TYPINGS: string = 'typings'; + + return `${moduleLocation}/${packageJson[TYPINGS]}`; +} diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/tsconfig.json b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/tsconfig.json new file mode 100644 index 00000000000..690e5e98537 --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "downlevelIteration": true + }, + "exclude": [ + "dist/**/*" + ] +} \ No newline at end of file diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/use_typings.js b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/use_typings.js new file mode 100644 index 00000000000..a1830bf2840 --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/use_typings.js @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * @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. + */ + +const { writeFileSync } = require('fs'); +const { argv } = require('yargs'); + +const path = require('path'); +const packageJsonPath = path.resolve(__dirname, './package.json'); + +// point typings field to the public d.ts file in package.json +const TYPINGS_PATH = argv.public + ? './dist/dummy-exp-public.d.ts' + : './dist/dummy-exp.d.ts'; +console.log( + `Updating the packages-exp/dummy-exp typings field to the ${ + argv.public ? 'public' : 'internal' + } d.ts file ${TYPINGS_PATH}` +); +const packageJson = require(packageJsonPath); +packageJson.typings = TYPINGS_PATH; + +writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, { + encoding: 'utf-8' +}); diff --git a/scripts/exp/modular-export-binary-size-analysis/test/test-exp/yarn.lock b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/yarn.lock new file mode 100644 index 00000000000..516ed5defe9 --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/test/test-exp/yarn.lock @@ -0,0 +1,971 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@firebase/logger@0.2.5": + version "0.2.5" + resolved "https://registry.npmjs.org/@firebase/logger/-/logger-0.2.5.tgz#bac27bfef32b36e3ecc4b9a5018e9441cb4765e6" + integrity sha512-qqw3m0tWs/qrg7axTZG/QZq24DIMdSY6dGoWuBn08ddq7+GLF5HiqkRj71XznYeUUbfRq5W9C/PSFnN4JxX+WA== + +"@rollup/plugin-alias@3.1.1": + version "3.1.1" + resolved "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-3.1.1.tgz#bb96cf37fefeb0a953a6566c284855c7d1cd290c" + integrity sha512-hNcQY4bpBUIvxekd26DBPgF7BT4mKVNDF5tBG4Zi+3IgwLxGYRY0itHs9D0oLVwXM5pvJDWJlBQro+au8WaUWw== + dependencies: + slash "^3.0.0" + +"@rollup/pluginutils@^3.0.8": + version "3.1.0" + resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" + integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== + dependencies: + "@types/estree" "0.0.39" + estree-walker "^1.0.1" + picomatch "^2.2.2" + +"@types/estree@*": + version "0.0.44" + resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.44.tgz#980cc5a29a3ef3bea6ff1f7d021047d7ea575e21" + integrity sha512-iaIVzr+w2ZJ5HkidlZ3EJM8VTZb2MJLCjw3V+505yVts0gRC4UMvjw0d1HPtGqI/HQC/KdsYtayfzl+AXY2R8g== + +"@types/estree@0.0.39": + version "0.0.39" + resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" + integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== + +"@types/node@*": + version "14.0.13" + resolved "https://registry.npmjs.org/@types/node/-/node-14.0.13.tgz#ee1128e881b874c371374c1f72201893616417c9" + integrity sha512-rouEWBImiRaSJsVA+ITTFM6ZxibuAlTuNOCyxVbwreu6k6+ujs7DfnU9o+PShFhET78pMBl3eH+AGSI5eOTkPA== + +acorn@^7.1.0: + version "7.3.1" + resolved "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" + integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +anymatch@~3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" + integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +array.prototype.map@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.2.tgz#9a4159f416458a23e9483078de1106b2ef68f8ec" + integrity sha512-Az3OYxgsa1g7xDYp86l0nnN4bcmuEITGe1rbdEBVkrqkzMgDcbdQ2R7r41pNzti+4NMces3H8gMmuioZUilLgw== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + es-array-method-boxes-properly "^1.0.0" + is-string "^1.0.4" + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +binary-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" + integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@~3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chokidar@3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450" + integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.3.0" + optionalDependencies: + fsevents "~2.1.2" + +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +debug@3.2.6: + version "3.2.6" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +define-properties@^1.1.2, define-properties@^1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + dependencies: + object-keys "^1.0.12" + +diff@4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +es-abstract@^1.17.0-next.1, es-abstract@^1.17.4, es-abstract@^1.17.5: + version "1.17.6" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" + integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw== + dependencies: + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + is-callable "^1.2.0" + is-regex "^1.1.0" + object-inspect "^1.7.0" + object-keys "^1.1.1" + object.assign "^4.1.0" + string.prototype.trimend "^1.0.1" + string.prototype.trimstart "^1.0.1" + +es-array-method-boxes-properly@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" + integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== + +es-get-iterator@^1.0.2: + version "1.1.0" + resolved "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.0.tgz#bb98ad9d6d63b31aacdc8f89d5d0ee57bcb5b4c8" + integrity sha512-UfrmHuWQlNMTs35e1ypnvikg6jCz3SK8v8ImvmDsh36fCVUR1MqoFDiyn0/k52C8NqO3YsO8Oe0azeesNuqSsQ== + dependencies: + es-abstract "^1.17.4" + has-symbols "^1.0.1" + is-arguments "^1.0.4" + is-map "^2.0.1" + is-set "^2.0.1" + is-string "^1.0.5" + isarray "^2.0.5" + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +escape-string-regexp@1.0.5, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +estree-walker@^0.6.1: + version "0.6.1" + resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" + integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w== + +estree-walker@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" + integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +find-cache-dir@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" + integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + +find-up@4.1.0, find-up@^4.0.0: + version "4.1.0" + resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +flat@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/flat/-/flat-4.1.0.tgz#090bec8b05e39cba309747f1d588f04dbaf98db2" + integrity sha512-Px/TiLIznH7gEDlPXcUD4KnBusa6kR6ayRUVcnEAbreRIuhkqow/mun59BuRXwoYk7ZQOLW1ZM05ilIvK38hFw== + dependencies: + is-buffer "~2.0.3" + +fs-extra@8.1.0: + version "8.1.0" + resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@~2.1.2: + version "2.1.3" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" + integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +get-caller-file@^2.0.1: + version "2.0.5" + resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +glob-parent@~5.1.0: + version "5.1.1" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229" + integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ== + dependencies: + is-glob "^4.0.1" + +glob@7.1.6: + version "7.1.6" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.4" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-symbols@^1.0.0, has-symbols@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" + integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-arguments@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3" + integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-buffer@~2.0.3: + version "2.0.4" + resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623" + integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A== + +is-callable@^1.1.4, is-callable@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb" + integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw== + +is-date-object@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" + integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" + integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== + dependencies: + is-extglob "^2.1.1" + +is-map@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1" + integrity sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw== + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.0.tgz#ece38e389e490df0dc21caea2bd596f987f767ff" + integrity sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw== + dependencies: + has-symbols "^1.0.1" + +is-set@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" + integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA== + +is-string@^1.0.4, is-string@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6" + integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== + +is-symbol@^1.0.2: + version "1.0.3" + resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" + integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== + dependencies: + has-symbols "^1.0.1" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +iterate-iterator@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.1.tgz#1693a768c1ddd79c969051459453f082fe82e9f6" + integrity sha512-3Q6tudGN05kbkDQDI4CqjaBf4qf85w6W6GnuZDtUVYwKgtC1q8yxYX7CZed7N+tLzQqS6roujWvszf13T+n9aw== + +iterate-value@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz#935115bd37d006a52046535ebc8d07e9c9337f57" + integrity sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ== + dependencies: + es-get-iterator "^1.0.2" + iterate-iterator "^1.0.1" + +js-yaml@3.13.1: + version "3.13.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" + integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= + optionalDependencies: + graceful-fs "^4.1.6" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +lodash@^4.17.15: + version "4.17.15" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" + integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== + +log-symbols@3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" + integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== + dependencies: + chalk "^2.4.2" + +magic-string@^0.25.2: + version "0.25.7" + resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" + integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== + dependencies: + sourcemap-codec "^1.4.4" + +make-dir@^3.0.2: + version "3.1.0" + resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +minimatch@3.0.4, minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +mocha@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/mocha/-/mocha-8.0.1.tgz#fe01f0530362df271aa8f99510447bc38b88d8ed" + integrity sha512-vefaXfdYI8+Yo8nPZQQi0QO2o+5q9UIMX1jZ1XMmK3+4+CQjc7+B0hPdUeglXiTlr8IHMVRo63IhO9Mzt6fxOg== + dependencies: + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.3.1" + debug "3.2.6" + diff "4.0.2" + escape-string-regexp "1.0.5" + find-up "4.1.0" + glob "7.1.6" + growl "1.10.5" + he "1.2.0" + js-yaml "3.13.1" + log-symbols "3.0.0" + minimatch "3.0.4" + ms "2.1.2" + object.assign "4.1.0" + promise.allsettled "1.0.2" + serialize-javascript "3.0.0" + strip-json-comments "3.0.1" + supports-color "7.1.0" + which "2.0.2" + wide-align "1.1.3" + workerpool "6.0.0" + yargs "13.3.2" + yargs-parser "13.1.2" + yargs-unparser "1.6.0" + +ms@2.1.2, ms@^2.1.1: + version "2.1.2" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +object-inspect@^1.7.0: + version "1.8.0" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" + integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== + +object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@4.1.0, object.assign@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" + integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +p-limit@^2.0.0, p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + +picomatch@^2.0.4, picomatch@^2.0.7, picomatch@^2.2.2: + version "2.2.2" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" + integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== + +pkg-dir@^4.1.0: + version "4.2.0" + resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +promise.allsettled@1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.2.tgz#d66f78fbb600e83e863d893e98b3d4376a9c47c9" + integrity sha512-UpcYW5S1RaNKT6pd+s9jp9K9rlQge1UXKskec0j6Mmuq7UJCvlS2J2/s/yuPN8ehftf9HXMxWlKiPbGGUzpoRg== + dependencies: + array.prototype.map "^1.0.1" + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + iterate-value "^1.0.0" + +readdirp@~3.3.0: + version "3.3.0" + resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17" + integrity sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ== + dependencies: + picomatch "^2.0.7" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +resolve@1.15.1: + version "1.15.1" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8" + integrity sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w== + dependencies: + path-parse "^1.0.6" + +rollup-plugin-json@4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/rollup-plugin-json/-/rollup-plugin-json-4.0.0.tgz#a18da0a4b30bf5ca1ee76ddb1422afbb84ae2b9e" + integrity sha512-hgb8N7Cgfw5SZAkb3jf0QXii6QX/FOkiIq2M7BAQIEydjHvTyxXHQiIzZaTFgx1GK0cRCHOCBHIyEkkLdWKxow== + dependencies: + rollup-pluginutils "^2.5.0" + +rollup-plugin-replace@2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.2.0.tgz#f41ae5372e11e7a217cde349c8b5d5fd115e70e3" + integrity sha512-/5bxtUPkDHyBJAKketb4NfaeZjL5yLZdeUihSfbF2PQMz+rSTEb8ARKoOl3UBT4m7/X+QOXJo3sLTcq+yMMYTA== + dependencies: + magic-string "^0.25.2" + rollup-pluginutils "^2.6.0" + +rollup-plugin-typescript2@0.27.0: + version "0.27.0" + resolved "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.27.0.tgz#95ff96f9e07d5000a9d2df4d76b548f9a1f83511" + integrity sha512-SRKG/Canve3cxBsqhY1apIBznqnX9X/WU3Lrq3XSwmTmFqccj3+//logLXFEmp+PYFNllSVng+f4zjqRTPKNkA== + dependencies: + "@rollup/pluginutils" "^3.0.8" + find-cache-dir "^3.3.1" + fs-extra "8.1.0" + resolve "1.15.1" + tslib "1.11.1" + +rollup-pluginutils@^2.5.0, rollup-pluginutils@^2.6.0: + version "2.8.2" + resolved "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" + integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== + dependencies: + estree-walker "^0.6.1" + +rollup@1.32.1: + version "1.32.1" + resolved "https://registry.npmjs.org/rollup/-/rollup-1.32.1.tgz#4480e52d9d9e2ae4b46ba0d9ddeaf3163940f9c4" + integrity sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A== + dependencies: + "@types/estree" "*" + "@types/node" "*" + acorn "^7.1.0" + +semver@^6.0.0: + version "6.3.0" + resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +serialize-javascript@3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.0.0.tgz#492e489a2d77b7b804ad391a5f5d97870952548e" + integrity sha512-skZcHYw2vEX4bw90nAr2iTTsz6x2SrHEnfxgKYmZlvJYBEZrvbKtobJWlQ20zczKb3bsHHXXTYt48zBA7ni9cw== + +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +sourcemap-codec@^1.4.4: + version "1.4.8" + resolved "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +"string-width@^1.0.2 || 2": + version "2.1.1" + resolved "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string-width@^3.0.0, string-width@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string.prototype.trimend@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" + integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +string.prototype.trimstart@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" + integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-json-comments@3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" + integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== + +supports-color@7.1.0: + version "7.1.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== + dependencies: + has-flag "^4.0.0" + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tslib@1.11.1: + version "1.11.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" + integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== + +typescript@3.8.3: + version "3.8.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" + integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +which@2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wide-align@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +workerpool@6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.0.0.tgz#85aad67fa1a2c8ef9386a1b43539900f61d03d58" + integrity sha512-fU2OcNA/GVAJLLyKUoHkAgIhKb0JoCpSjLC/G2vYKxUjVmQwGbRVeoPJ1a8U4pnVofz4AQV5Y/NEw8oKqxEBtA== + +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +y18n@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + +yargs-parser@13.1.2, yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-unparser@1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.0.tgz#ef25c2c769ff6bd09e4b0f9d7c605fb27846ea9f" + integrity sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw== + dependencies: + flat "^4.1.0" + lodash "^4.17.15" + yargs "^13.3.0" + +yargs@13.3.2, yargs@^13.3.0: + version "13.3.2" + resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" diff --git a/scripts/exp/modular-export-binary-size-analysis/tsconfig.json b/scripts/exp/modular-export-binary-size-analysis/tsconfig.json new file mode 100644 index 00000000000..d7b9bfb8bad --- /dev/null +++ b/scripts/exp/modular-export-binary-size-analysis/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "importHelpers": true, + "module": "commonjs", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "es5", + "esModuleInterop": true + } +} \ No newline at end of file diff --git a/scripts/report_binary_size.js b/scripts/report_binary_size.js index e4f217a1171..474488ad66c 100644 --- a/scripts/report_binary_size.js +++ b/scripts/report_binary_size.js @@ -29,6 +29,8 @@ const METRICS_SERVICE_URL = process.env.METRICS_SERVICE_URL; function generateReportForCDNScripts() { const reports = []; const firebaseRoot = resolve(__dirname, '../packages/firebase'); + // console.log(__dirname); // /Users/xuechunhou/Desktop/Google/firebase-js-sdk/scripts + // console.log(firebaseRoot); // /Users/xuechunhou/Desktop/Google/firebase-js-sdk/packages/firebase const pkgJson = require(`${firebaseRoot}/package.json`); const special_files = [ @@ -44,6 +46,7 @@ function generateReportForCDNScripts() { component => `${firebaseRoot}/firebase-${component}.js` ) ]; + //console.log(files); for (const file of files) { const { size } = fs.statSync(file); @@ -70,7 +73,7 @@ function generateReportForNPMPackages() { const packageInfo = JSON.parse( execSync('npx lerna ls --json --scope @firebase/*').toString() ); - + //console.log(packageInfo); for (const package of packageInfo) { // we traverse the dir in order to include binaries for submodules, e.g. @firebase/firestore/memory // Currently we only traverse 1 level deep because we don't have any submodule deeper than that. @@ -84,7 +87,7 @@ function generateReportForNPMPackages() { } const packageJson = require(packageJsonPath); - + console.log(packageJson); for (const field of fields) { if (packageJson[field]) { const filePath = `${path}/${packageJson[field]}`; diff --git a/yarn.lock b/yarn.lock index 81e115ca0bb..14641075bcf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -591,9 +591,9 @@ "@babel/helper-plugin-utils" "^7.8.3" "@babel/plugin-transform-named-capturing-groups-regex@^7.8.3": - version "7.8.3" - resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.8.3.tgz#a2a72bffa202ac0e2d0506afd0939c5ecbc48c6c" - integrity sha512-f+tF/8UVPU86TrCb06JoPWIdDpTNSGGcAtaD9mLP0aYGA0OS0j7j7DHJR0GTFrUZPUU6loZhbsVZgTh0N+Qdnw== + version "7.10.3" + resolved "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.3.tgz#a4f8444d1c5a46f35834a410285f2c901c007ca6" + integrity sha512-I3EH+RMFyVi8Iy/LekQm948Z4Lz4yKT7rK+vuCAeRm0kTa6Z5W7xuhRxDNJv0FPya/her6AUgrDITb70YHtTvA== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.8.3" @@ -996,13 +996,6 @@ dependencies: semver "^6.2.0" -"@grpc/grpc-js@^1.0.0": - version "1.0.5" - resolved "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.0.5.tgz#09948c0810e62828fdd61455b2eb13d7879888b0" - integrity sha512-Hm+xOiqAhcpT9RYM8lc15dbQD7aQurM7ZU8ulmulepiPlN7iwBXXwP3vSBUimoFoApRqz7pSIisXU8pZaCB4og== - dependencies: - semver "^6.2.0" - "@grpc/proto-loader@^0.5.0", "@grpc/proto-loader@^0.5.1": version "0.5.4" resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.5.4.tgz#038a3820540f621eeb1b05d81fbedfb045e14de0" @@ -1967,6 +1960,50 @@ dependencies: slash "^3.0.0" +"@rollup/plugin-commonjs@13.0.0": + version "13.0.0" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-13.0.0.tgz#8a1d684ba6848afe8b9e3d85649d4b2f6f7217ec" + integrity sha512-Anxc3qgkAi7peAyesTqGYidG5GRim9jtg8xhmykNaZkImtvjA7Wsqep08D2mYsqw1IF7rA3lYfciLgzUSgRoqw== + dependencies: + "@rollup/pluginutils" "^3.0.8" + commondir "^1.0.1" + estree-walker "^1.0.1" + glob "^7.1.2" + is-reference "^1.1.2" + magic-string "^0.25.2" + resolve "^1.11.0" + +"@rollup/plugin-node-resolve@8.1.0": + version "8.1.0" + resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-8.1.0.tgz#1da5f3d0ccabc8f66f5e3c74462aad76cfd96c47" + integrity sha512-ovq7ZM3JJYUUmEjjO+H8tnUdmQmdQudJB7xruX8LFZ1W2q8jXdPUS6SsIYip8ByOApu4RR7729Am9WhCeCMiHA== + dependencies: + "@rollup/pluginutils" "^3.0.8" + "@types/resolve" "0.0.8" + builtin-modules "^3.1.0" + deep-freeze "^0.0.1" + deepmerge "^4.2.2" + is-module "^1.0.0" + resolve "^1.14.2" + +"@rollup/plugin-strip@^1.3.2": + version "1.3.3" + resolved "https://registry.npmjs.org/@rollup/plugin-strip/-/plugin-strip-1.3.3.tgz#cf4380020414217affd467da14ebb65a303a7848" + integrity sha512-jBZYNi9Wa5lSv8wXUepgqLFcv5PMJiP2VcWSIqjAzYOwZiQA09e3vwtar9EYHSYfIONyc1hG0IkSDz2VvGBZcA== + dependencies: + "@rollup/pluginutils" "^3.0.4" + estree-walker "^1.0.1" + magic-string "^0.25.5" + +"@rollup/pluginutils@^3.0.4": + version "3.1.0" + resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b" + integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg== + dependencies: + "@types/estree" "0.0.39" + estree-walker "^1.0.1" + picomatch "^2.2.2" + "@rollup/pluginutils@^3.0.8": version "3.0.9" resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.0.9.tgz#aa6adca2c45e5a1b950103a999e3cddfe49fd775" @@ -2653,9 +2690,9 @@ ajv-errors@^1.0.0: integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== ajv-keywords@^3.1.0, ajv-keywords@^3.4.1: - version "3.4.1" - resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" - integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== + version "3.5.0" + resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.0.tgz#5c894537098785926d71e696114a53ce768ed773" + integrity sha512-eyoaac3btgU8eJlvh01En8OCKzRqlLe2G5jDsCr3RiE2uLGMEEB1aaGwVVpwR8M95956tGH6R+9edC++OvzaVw== ajv@^5.0.0: version "5.5.2" @@ -5167,11 +5204,21 @@ deep-extend@^0.6.0: resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== +deep-freeze@^0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz#3a0b0005de18672819dfd38cd31f91179c893e84" + integrity sha1-OgsABd4YZygZ39OM0x+RF5yJPoQ= + deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.3" resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= +deepmerge@^4.2.2: + version "4.2.2" + resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" + integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== + default-compare@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz#cb61131844ad84d84788fb68fd01681ca7781a2f" @@ -9939,7 +9986,7 @@ macos-release@^2.2.0: resolved "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" integrity sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA== -magic-string@0.25.7, magic-string@^0.25.2: +magic-string@0.25.7, magic-string@^0.25.2, magic-string@^0.25.5: version "0.25.7" resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== @@ -10934,9 +10981,9 @@ object-copy@^0.1.0: kind-of "^3.0.3" object-inspect@^1.7.0: - version "1.7.0" - resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" - integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== + version "1.8.0" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" + integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== object-is@^1.0.1: version "1.0.2" @@ -12522,6 +12569,13 @@ resolve@1.8.1: dependencies: path-parse "^1.0.5" +resolve@^1.14.2: + version "1.17.0" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" + integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== + dependencies: + path-parse "^1.0.6" + restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -12677,6 +12731,17 @@ rollup-plugin-terser@5.3.0: serialize-javascript "^2.1.2" terser "^4.6.2" +rollup-plugin-typescript2@0.26.0: + version "0.26.0" + resolved "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.26.0.tgz#cee2b44d51d9623686656d76dc30a73c4de91672" + integrity sha512-lUK7XZVG77tu8dmv1L/0LZFlavED/5Yo6e4iMMl6fdox/yKdj4IFRRPPJEXNdmEaT1nDQQeCi7b5IwKHffMNeg== + dependencies: + find-cache-dir "^3.2.0" + fs-extra "8.1.0" + resolve "1.15.1" + rollup-pluginutils "2.8.2" + tslib "1.10.0" + rollup-plugin-typescript2@0.27.0: version "0.27.0" resolved "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.27.0.tgz#95ff96f9e07d5000a9d2df4d76b548f9a1f83511" @@ -12698,7 +12763,7 @@ rollup-plugin-uglify@6.0.4: serialize-javascript "^2.1.2" uglify-js "^3.4.9" -rollup-pluginutils@^2.5.0, rollup-pluginutils@^2.6.0, rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2: +rollup-pluginutils@2.8.2, rollup-pluginutils@^2.5.0, rollup-pluginutils@^2.6.0, rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2: version "2.8.2" resolved "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== @@ -14014,9 +14079,9 @@ thenify-all@^1.0.0: thenify ">= 3.1.0 < 4" "thenify@>= 3.1.0 < 4": - version "3.3.0" - resolved "https://registry.npmjs.org/thenify/-/thenify-3.3.0.tgz#e69e38a1babe969b0108207978b9f62b88604839" - integrity sha1-5p44obq+lpsBCCB5eLn2K4hgSDk= + version "3.3.1" + resolved "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== dependencies: any-promise "^1.0.0" @@ -14288,6 +14353,11 @@ ts-node@8.10.1: source-map-support "^0.5.17" yn "3.1.1" +tslib@1.10.0: + version "1.10.0" + resolved "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" + integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== + tslib@1.11.1, tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: version "1.11.1" resolved "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"