diff --git a/.changeset/fast-mangos-serve.md b/.changeset/fast-mangos-serve.md new file mode 100644 index 00000000000..042f2e9718b --- /dev/null +++ b/.changeset/fast-mangos-serve.md @@ -0,0 +1,5 @@ +--- +"@firebase/auth": patch +--- + +Ensure emulator warning text is accessible. diff --git a/.changeset/perfect-comics-march.md b/.changeset/perfect-comics-march.md new file mode 100644 index 00000000000..c7e0f24da3c --- /dev/null +++ b/.changeset/perfect-comics-march.md @@ -0,0 +1,5 @@ +--- +"@firebase/firestore": patch +--- + +Use 'pagehide' for page termination by default. diff --git a/.changeset/silent-planets-raise.md b/.changeset/silent-planets-raise.md new file mode 100644 index 00000000000..fa2a2d34eca --- /dev/null +++ b/.changeset/silent-planets-raise.md @@ -0,0 +1,5 @@ +--- +"@firebase/rules-unit-testing": patch +--- + +Allow using useEmulators() with only the storage configuration. diff --git a/.changeset/tender-eels-greet.md b/.changeset/tender-eels-greet.md new file mode 100644 index 00000000000..f56854d05e1 --- /dev/null +++ b/.changeset/tender-eels-greet.md @@ -0,0 +1,5 @@ +--- +'@firebase/functions': patch +--- + +Fixed a bug in `httpsCallable()` when used in the same project as Firebase Messaging. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4cb5320fcd1..6a7a7c4e56b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -55,12 +55,12 @@ packages/messaging-types @zwu52 @chliangGoogle @ciarand @firebase/jssdk-global-a integration/messaging @zwu52 @chliangGoogle @ciarand @firebase/jssdk-global-approvers # Auth Code -packages/auth @bojeil-google @avolkovi @samhorlbeck @yuchenshi @firebase/jssdk-global-approvers -packages/auth-types @bojeil-google @avolkovi @samhorlbeck @yuchenshi @firebase/jssdk-global-approvers +packages/auth @bojeil-google @avolkovi @sam-gc @yuchenshi @firebase/jssdk-global-approvers +packages/auth-types @bojeil-google @avolkovi @sam-gc @yuchenshi @firebase/jssdk-global-approvers # Testing Code -packages/testing @avolkovi @samhorlbeck @yuchenshi @firebase/jssdk-global-approvers -packages/rules-unit-testing @avolkovi @samhorlbeck @yuchenshi @firebase/jssdk-global-approvers +packages/testing @avolkovi @sam-gc @yuchenshi @firebase/jssdk-global-approvers +packages/rules-unit-testing @avolkovi @sam-gc @yuchenshi @firebase/jssdk-global-approvers # RxFire Code packages/rxfire @davideast @jamesdaniels @firebase/jssdk-global-approvers @@ -89,8 +89,8 @@ scripts/docgen/content-sources/ @firebase/firebase-techwriters @firebase/jssdk-g .changeset @firebase/firebase-techwriters @firebase/jssdk-changeset-approvers @firebase/firestore-js-team @firebase/jssdk-global-approvers # Auth-Exp Code -packages-exp/auth-exp @avolkovi @samhorlbeck @yuchenshi @firebase/jssdk-global-approvers -packages-exp/auth-compat-exp @avolkovi @samhorlbeck @yuchenshi @firebase/jssdk-global-approvers +packages-exp/auth-exp @avolkovi @sam-gc @yuchenshi @firebase/jssdk-global-approvers +packages-exp/auth-compat-exp @avolkovi @sam-gc @yuchenshi @firebase/jssdk-global-approvers # Installations-Exp Code packages/installations-exp @andirayo @ChaoqunCHEN @firebase/jssdk-global-approvers diff --git a/.github/ISSUE_TEMPLATE/alpha_bug_report.md b/.github/ISSUE_TEMPLATE/alpha_bug_report.md deleted file mode 100644 index dfe2b874103..00000000000 --- a/.github/ISSUE_TEMPLATE/alpha_bug_report.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: 🧪 Alpha SDK bug report -about: Have you found a bug in one of our pre-release or alpha SDKs? File it here. -title: '' -labels: alpha -assignees: '' - ---- - - - - - - -### [REQUIRED] Describe your environment - - * Operating System version: _____ - * Browser version: _____ - * Firebase SDK version: _____ - * Firebase Product: _____ (auth, database, storage, etc) - - - -### [REQUIRED] Describe the problem - -#### Steps to reproduce: - -#### Relevant Code: - -```javascript -// TODO(you): code here to reproduce the problem -``` diff --git a/.github/workflows/canary-deploy.yml b/.github/workflows/canary-deploy.yml index c4e45416e11..597b24e238d 100644 --- a/.github/workflows/canary-deploy.yml +++ b/.github/workflows/canary-deploy.yml @@ -30,6 +30,9 @@ jobs: NPM_TOKEN_ANALYTICS_TYPES: ${{secrets.NPM_TOKEN_ANALYTICS_TYPES}} NPM_TOKEN_APP: ${{secrets.NPM_TOKEN_APP}} NPM_TOKEN_APP_TYPES: ${{secrets.NPM_TOKEN_APP_TYPES}} + NPM_TOKEN_APP_CHECK: ${{secrets.NPM_TOKEN_APP_CHECK}} + NPM_TOKEN_APP_CHECK_INTEROP_TYPES: ${{secrets.NPM_TOKEN_APP_CHECK_INTEROP_TYPES}} + NPM_TOKEN_APP_CHECK_TYPES: ${{secrets.NPM_TOKEN_APP_CHECK_TYPES}} NPM_TOKEN_AUTH: ${{secrets.NPM_TOKEN_AUTH}} NPM_TOKEN_AUTH_INTEROP_TYPES: ${{secrets.NPM_TOKEN_AUTH_INTEROP_TYPES}} NPM_TOKEN_AUTH_TYPES: ${{secrets.NPM_TOKEN_AUTH_TYPES}} diff --git a/.github/workflows/health-metrics-test.yml b/.github/workflows/health-metrics-test.yml index 40b587c4667..c1a01cefda5 100644 --- a/.github/workflows/health-metrics-test.yml +++ b/.github/workflows/health-metrics-test.yml @@ -6,6 +6,7 @@ env: METRICS_SERVICE_URL: ${{ secrets.METRICS_SERVICE_URL }} GITHUB_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} GITHUB_PULL_REQUEST_BASE_SHA: ${{ github.event.pull_request.base.sha }} + NODE_OPTIONS: "--max-old-space-size=4096" jobs: binary-size: diff --git a/common/api-review/database.api.md b/common/api-review/database.api.md index 7bdcbe08dae..ad8490ac286 100644 --- a/common/api-review/database.api.md +++ b/common/api-review/database.api.md @@ -4,9 +4,10 @@ ```ts +import { EmulatorMockTokenOptions } from '@firebase/util'; import { FirebaseApp } from '@firebase/app'; -// @public (undocumented) +// @public export function child(parent: Reference, path: string): Reference; // @public @@ -229,7 +230,9 @@ export type Unsubscribe = () => void; export function update(ref: Reference, values: object): Promise; // @public -export function useDatabaseEmulator(db: FirebaseDatabase, host: string, port: number): void; +export function useDatabaseEmulator(db: FirebaseDatabase, host: string, port: number, options?: { + mockUserToken?: EmulatorMockTokenOptions; +}): void; ``` diff --git a/common/api-review/storage.api.md b/common/api-review/storage.api.md index bbe983dbcc9..2847b663aac 100644 --- a/common/api-review/storage.api.md +++ b/common/api-review/storage.api.md @@ -4,6 +4,7 @@ ```ts +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { CompleteFn } from '@firebase/util'; import { FirebaseApp } from '@firebase/app'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; diff --git a/integration/firebase/package.json b/integration/firebase/package.json index 614e11f8dfe..2313cb7fba8 100644 --- a/integration/firebase/package.json +++ b/integration/firebase/package.json @@ -7,7 +7,7 @@ "test:ci": "node ../../scripts/run_tests_in_ci.js -s test" }, "devDependencies": { - "firebase": "8.4.1", + "firebase": "8.6.1", "@types/chai": "4.2.14", "@types/mocha": "7.0.2", "chai": "4.2.0", diff --git a/integration/firestore/package.json b/integration/firestore/package.json index b4b21d415ab..3dba597f820 100644 --- a/integration/firestore/package.json +++ b/integration/firestore/package.json @@ -14,8 +14,8 @@ "test:memory:debug": "yarn build:memory; karma start --auto-watch --browsers Chrome" }, "devDependencies": { - "@firebase/app": "0.6.20", - "@firebase/firestore": "2.2.4", + "@firebase/app": "0.6.22", + "@firebase/firestore": "2.3.0", "@types/mocha": "7.0.2", "gulp": "4.0.2", "gulp-filter": "6.0.0", diff --git a/integration/messaging/package.json b/integration/messaging/package.json index e4703091f02..5782d21c75d 100644 --- a/integration/messaging/package.json +++ b/integration/messaging/package.json @@ -9,7 +9,7 @@ "test:manual": "mocha --exit" }, "devDependencies": { - "firebase": "8.4.1", + "firebase": "8.6.1", "chai": "4.2.0", "chromedriver": "89.0.0", "express": "4.17.1", diff --git a/packages-exp/analytics-compat/package.json b/packages-exp/analytics-compat/package.json index 697ad28c853..3f6842ccbee 100644 --- a/packages-exp/analytics-compat/package.json +++ b/packages-exp/analytics-compat/package.json @@ -44,10 +44,10 @@ }, "typings": "dist/src/index.d.ts", "dependencies": { - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "@firebase/analytics-exp": "0.0.900", "@firebase/analytics-types": "0.4.0", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "tslib": "^2.1.0" }, "nyc": { diff --git a/packages-exp/analytics-exp/package.json b/packages-exp/analytics-exp/package.json index 0fd297540ec..235c15940f1 100644 --- a/packages-exp/analytics-exp/package.json +++ b/packages-exp/analytics-exp/package.json @@ -34,8 +34,8 @@ "dependencies": { "@firebase/installations-exp": "0.0.900", "@firebase/logger": "0.2.6", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages-exp/app-compat/package.json b/packages-exp/app-compat/package.json index be80f0cf02a..b0716ae0f95 100644 --- a/packages-exp/app-compat/package.json +++ b/packages-exp/app-compat/package.json @@ -29,9 +29,9 @@ "license": "Apache-2.0", "dependencies": { "@firebase/app-exp": "0.0.900", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "@firebase/logger": "0.2.6", - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "tslib": "^2.1.0", "dom-storage": "2.1.0", "xmlhttprequest": "1.8.0" diff --git a/packages-exp/app-compat/src/firebaseApp.ts b/packages-exp/app-compat/src/firebaseApp.ts index 13ee7564810..e3ebe336bc0 100644 --- a/packages-exp/app-compat/src/firebaseApp.ts +++ b/packages-exp/app-compat/src/firebaseApp.ts @@ -20,6 +20,7 @@ import { Component, ComponentContainer, ComponentType, + InstantiationMode, Name } from '@firebase/component'; import { @@ -121,8 +122,17 @@ export class FirebaseAppImpl implements Compat<_FirebaseAppExp>, _FirebaseApp { ): _FirebaseService { this._delegate.checkDestroyed(); + // Initialize instance if InstatiationMode is `EXPLICIT`. + const provider = this._delegate.container.getProvider(name as Name); + if ( + !provider.isInitialized() && + provider.getComponent()?.instantiationMode === InstantiationMode.EXPLICIT + ) { + provider.initialize(); + } + // getImmediate will always succeed because _getService is only called for registered components. - return (this._delegate.container.getProvider(name as Name).getImmediate({ + return (provider.getImmediate({ identifier: instanceIdentifier }) as unknown) as _FirebaseService; } diff --git a/packages-exp/app-compat/src/lite/firebaseNamespaceLite.ts b/packages-exp/app-compat/src/lite/firebaseNamespaceLite.ts index cffc7c25b33..e73e6319726 100644 --- a/packages-exp/app-compat/src/lite/firebaseNamespaceLite.ts +++ b/packages-exp/app-compat/src/lite/firebaseNamespaceLite.ts @@ -40,8 +40,8 @@ export function createFirebaseNamespaceLite(): FirebaseNamespace { // only allow performance to register with firebase lite if ( component.type === ComponentType.PUBLIC && - component.name !== 'performance' && - component.name !== 'installations' + !component.name.includes('performance') && + !component.name.includes('installations') ) { throw Error(`${name} cannot register with the standalone perf instance`); } diff --git a/packages-exp/app-compat/test/firebaseAppCompat.test.ts b/packages-exp/app-compat/test/firebaseAppCompat.test.ts index 1b92f20e996..1e6cbf034c4 100644 --- a/packages-exp/app-compat/test/firebaseAppCompat.test.ts +++ b/packages-exp/app-compat/test/firebaseAppCompat.test.ts @@ -21,7 +21,11 @@ import { stub } from 'sinon'; import { FirebaseNamespace, FirebaseOptions } from '../src/public-types'; import { _FirebaseApp, _FirebaseNamespace } from '../src/types'; import { _components, _clearComponents } from '@firebase/app-exp'; -import { ComponentType } from '@firebase/component'; +import { + Component, + ComponentType, + InstantiationMode +} from '@firebase/component'; import { createFirebaseNamespace } from '../src/firebaseNamespace'; import { createFirebaseNamespaceLite } from '../src/lite/firebaseNamespaceLite'; @@ -67,6 +71,7 @@ function executeFirebaseTests(): void { expect(serviceNamespace).to.eq(serviceNamespace2); expect(registerStub).to.have.not.thrown(); + registerStub.restore(); }); it('returns cached service instances', () => { @@ -80,6 +85,71 @@ function executeFirebaseTests(): void { expect(service).to.eq((firebase as any).test()); }); + it('does not instantiate explicit components unless called explicitly', () => { + firebase.initializeApp({}); + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('explicit1').setInstantiationMode( + InstantiationMode.EXPLICIT + ) + ); + + let explicitService; + + // Expect getImmediate in a consuming component to return null. + const consumerComponent = new Component( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'consumer' as any, + container => { + explicitService = container + .getProvider('explicit1' as any) + .getImmediate({ optional: true }); + return new TestService( + container.getProvider('app-compat').getImmediate() + ); + }, + ComponentType.PUBLIC + ); + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + consumerComponent + ); + + (firebase as any).consumer(); + expect(explicitService).to.be.null; + }); + + it('does instantiate explicit components when called explicitly', () => { + firebase.initializeApp({}); + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('explicit2').setInstantiationMode( + InstantiationMode.EXPLICIT + ) + ); + + let explicitService; + + // Expect getImmediate in a consuming component to return the service. + const consumerComponent = new Component( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'consumer' as any, + container => { + explicitService = container + .getProvider('explicit2' as any) + .getImmediate({ optional: true }); + return new TestService( + container.getProvider('app-compat').getImmediate() + ); + }, + ComponentType.PUBLIC + ); + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + consumerComponent + ); + + (firebase as any).explicit2(); + (firebase as any).consumer(); + expect(explicitService).to.not.be.null; + }); + it(`creates a new instance of a service after removing the existing instance`, () => { const app = firebase.initializeApp({}); (firebase as _FirebaseNamespace).INTERNAL.registerComponent( diff --git a/packages-exp/app-exp/package.json b/packages-exp/app-exp/package.json index 05ff4095de8..78323ec8470 100644 --- a/packages-exp/app-exp/package.json +++ b/packages-exp/app-exp/package.json @@ -30,9 +30,9 @@ "typings:internal": "node ../../scripts/exp/use_typings.js ./dist/app-exp.d.ts" }, "dependencies": { - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "@firebase/logger": "0.2.6", - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages-exp/auth-compat-exp/package.json b/packages-exp/auth-compat-exp/package.json index 57960ec766f..760429a8fdf 100644 --- a/packages-exp/auth-compat-exp/package.json +++ b/packages-exp/auth-compat-exp/package.json @@ -33,9 +33,9 @@ }, "dependencies": { "@firebase/auth-exp": "0.0.900", - "@firebase/auth-types": "0.10.2", - "@firebase/component": "0.4.1", - "@firebase/util": "1.0.0", + "@firebase/auth-types": "0.10.3", + "@firebase/component": "0.5.0", + "@firebase/util": "1.1.0", "node-fetch": "2.6.1", "selenium-webdriver": "^4.0.0-beta.2", "tslib": "^2.1.0" diff --git a/packages-exp/auth-compat-exp/src/auth.ts b/packages-exp/auth-compat-exp/src/auth.ts index dd4b9d22f81..a4ea71f359d 100644 --- a/packages-exp/auth-compat-exp/src/auth.ts +++ b/packages-exp/auth-compat-exp/src/auth.ts @@ -170,6 +170,14 @@ export class Auth } return convertCredential(this._delegate, Promise.resolve(credential)); } + + // This function should only be called by frameworks (e.g. FirebaseUI-web) to log their usage. + // It is not intended for direct use by developer apps. NO jsdoc here to intentionally leave it + // out of autogenerated documentation pages to reduce accidental misuse. + addFrameworkForLogging(framework: string): void { + exp.addFrameworkForLogging(this._delegate, framework); + } + onAuthStateChanged( nextOrObserver: Observer | ((a: compat.User | null) => unknown), errorFn?: (error: compat.Error) => unknown, diff --git a/packages-exp/auth-exp/internal/index.ts b/packages-exp/auth-exp/internal/index.ts index aa84a0fe36d..d8da272c895 100644 --- a/packages-exp/auth-exp/internal/index.ts +++ b/packages-exp/auth-exp/internal/index.ts @@ -15,6 +15,9 @@ * limitations under the License. */ +import { _castAuth } from '../src/core/auth/auth_impl'; +import { Auth } from '../src/model/public_types'; + /** * This interface is intended only for use by @firebase/auth-compat-exp, do not use directly */ @@ -45,3 +48,10 @@ export { _getRedirectResult } from '../src/platform_browser/strategies/redirect' export { cordovaPopupRedirectResolver } from '../src/platform_cordova/popup_redirect/popup_redirect'; export { FetchProvider } from '../src/core/util/fetch_provider'; export { SAMLAuthCredential } from '../src/core/credentials/saml'; + +// This function should only be called by frameworks (e.g. FirebaseUI-web) to log their usage. +// It is not intended for direct use by developer apps. NO jsdoc here to intentionally leave it out +// of autogenerated documentation pages to reduce accidental misuse. +export function addFrameworkForLogging(auth: Auth, framework: string): void { + _castAuth(auth)._logFramework(framework); +} diff --git a/packages-exp/auth-exp/package.json b/packages-exp/auth-exp/package.json index 523b2570f50..d5b32905398 100644 --- a/packages-exp/auth-exp/package.json +++ b/packages-exp/auth-exp/package.json @@ -49,9 +49,9 @@ "@firebase/app-exp": "0.x" }, "dependencies": { - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "@firebase/logger": "0.2.6", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "node-fetch": "2.6.1", "selenium-webdriver": "4.0.0-beta.1", "tslib": "^2.1.0" diff --git a/packages-exp/auth-exp/src/core/auth/auth_impl.ts b/packages-exp/auth-exp/src/core/auth/auth_impl.ts index ae52a2ae16f..31c1e991e3f 100644 --- a/packages-exp/auth-exp/src/core/auth/auth_impl.ts +++ b/packages-exp/auth-exp/src/core/auth/auth_impl.ts @@ -562,7 +562,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { private frameworks: string[] = []; private clientVersion: string; _logFramework(framework: string): void { - if (this.frameworks.includes(framework)) { + if (!framework || this.frameworks.includes(framework)) { return; } this.frameworks.push(framework); diff --git a/packages-exp/auth-exp/src/core/auth/emulator.ts b/packages-exp/auth-exp/src/core/auth/emulator.ts index ff0361f131b..78e245812b2 100644 --- a/packages-exp/auth-exp/src/core/auth/emulator.ts +++ b/packages-exp/auth-exp/src/core/auth/emulator.ts @@ -122,7 +122,7 @@ function emitEmulatorWarning(disableBanner: boolean): void { sty.width = '100%'; sty.backgroundColor = '#ffffff'; sty.border = '.1em solid #000000'; - sty.color = '#ff0000'; + sty.color = '#b50000'; sty.bottom = '0px'; sty.left = '0px'; sty.margin = '0px'; diff --git a/packages-exp/auth-exp/src/core/user/token_manager.ts b/packages-exp/auth-exp/src/core/user/token_manager.ts index 44f8f68b71a..5f56f88afb6 100644 --- a/packages-exp/auth-exp/src/core/user/token_manager.ts +++ b/packages-exp/auth-exp/src/core/user/token_manager.ts @@ -32,6 +32,12 @@ export const enum Buffer { TOKEN_REFRESH = 30_000 } +/** + * We need to mark this class as internal explicitly to exclude it in the public typings, because + * it references AuthInternal which has a circular dependency with UserInternal. + * + * @internal + */ export class StsTokenManager { refreshToken: string | null = null; accessToken: string | null = null; diff --git a/packages-exp/auth-exp/src/core/util/version.ts b/packages-exp/auth-exp/src/core/util/version.ts index fe84758fe1a..e8aecd48f17 100644 --- a/packages-exp/auth-exp/src/core/util/version.ts +++ b/packages-exp/auth-exp/src/core/util/version.ts @@ -31,18 +31,8 @@ export const enum ClientPlatform { WORKER = 'Worker' } -const 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, @@ -65,6 +55,6 @@ export function _getClientVersion( } const reportedFrameworks = frameworks.length ? frameworks.join(',') - : ClientFramework.DEFAULT; + : 'FirebaseCore-web'; /* default value if no other framework is used */ return `${reportedPlatform}/${ClientImplementation.CORE}/${SDK_VERSION}/${reportedFrameworks}`; } diff --git a/packages-exp/auth-exp/src/model/auth.ts b/packages-exp/auth-exp/src/model/auth.ts index c153a4c113a..ccb538ad124 100644 --- a/packages-exp/auth-exp/src/model/auth.ts +++ b/packages-exp/auth-exp/src/model/auth.ts @@ -48,6 +48,12 @@ export interface ConfigInternal extends Config { clientPlatform: ClientPlatform; } +/** + * UserInternal and AuthInternal reference each other, so both of them are included in the public typings. + * In order to exclude them, we mark them as internal explicitly. + * + * @internal + */ export interface AuthInternal extends Auth { currentUser: User | null; emulatorConfig: EmulatorConfig | null; diff --git a/packages-exp/auth-exp/src/model/popup_redirect.ts b/packages-exp/auth-exp/src/model/popup_redirect.ts index b2c658c398c..a0b6ba6b3a9 100644 --- a/packages-exp/auth-exp/src/model/popup_redirect.ts +++ b/packages-exp/auth-exp/src/model/popup_redirect.ts @@ -79,6 +79,12 @@ export interface EventManager { unregisterConsumer(authEventConsumer: AuthEventConsumer): void; } +/** + * We need to mark this interface as internal explicitly to exclude it in the public typings, because + * it references AuthInternal which has a circular dependency with UserInternal. + * + * @internal + */ export interface PopupRedirectResolverInternal extends PopupRedirectResolver { // Whether or not to initialize the event manager early _shouldInitProactively: boolean; diff --git a/packages-exp/auth-exp/src/model/user.ts b/packages-exp/auth-exp/src/model/user.ts index 24f9151bb51..9f0802a2423 100644 --- a/packages-exp/auth-exp/src/model/user.ts +++ b/packages-exp/auth-exp/src/model/user.ts @@ -52,6 +52,12 @@ export interface UserParameters { lastLoginAt?: string | null; } +/** + * UserInternal and AuthInternal reference each other, so both of them are included in the public typings. + * In order to exclude them, we mark them as internal explicitly. + * + * @internal + */ export interface UserInternal extends User { displayName: string | null; email: string | null; 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 index e1d09045bdf..212c81b0d1f 100644 --- a/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_loader.ts +++ b/packages-exp/auth-exp/src/platform_browser/recaptcha/recaptcha_loader.ts @@ -32,6 +32,12 @@ 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?'; +/** + * We need to mark this interface as internal explicitly to exclude it in the public typings, because + * it references AuthInternal which has a circular dependency with UserInternal. + * + * @internal + */ export interface ReCaptchaLoader { load(auth: AuthInternal, hl?: string): Promise; clearedOneInstance(): void; diff --git a/packages-exp/firebase-exp/compat/rollup.config.release.js b/packages-exp/firebase-exp/compat/rollup.config.release.js index 9910f332eed..4fa04b5ecfd 100644 --- a/packages-exp/firebase-exp/compat/rollup.config.release.js +++ b/packages-exp/firebase-exp/compat/rollup.config.release.js @@ -206,6 +206,40 @@ const componentBuilds = compatPkg.components }) .reduce((a, b) => a.concat(b), []); +const aliasForCompleteCDNBuild = compatPkg.components + .filter(component => { + return ( + component !== 'firestore' && + component !== 'storage' && + component !== 'database' + ); + }) + .map(component => ({ + find: `@firebase/${component}`, + replacement: `@firebase/${component}-exp` + })) + .concat([ + { + find: '@firebase/installations', + replacement: '@firebase/installations-exp' + }, + { + // hack to locate firestore-compat + find: '@firebase/firestore-compat', + replacement: 'firestore-compat' + }, + { + // hack to locate storage-compat + find: '@firebase/storage-compat', + replacement: 'storage-compat' + }, + { + // hack to locate database-compat + find: '@firebase/database-compat', + replacement: 'database-compat' + } + ]); + /** * Complete Package Builds */ @@ -230,10 +264,19 @@ const completeBuilds = [ output: { file: 'firebase-compat.js', format: 'umd', - sourcemap: true, + // disable sourcemap, otherwise build will fail with Error: Multiple conflicting contents for sourcemap source + // TODO: I think it's related to the alias() we are using, so let's try to reenable it for GA. + sourcemap: false, name: GLOBAL_NAME }, - plugins: [...plugins, typescriptPluginUMD, terser()] + plugins: [ + ...plugins, + typescriptPluginUMD, + terser(), + alias({ + entries: aliasForCompleteCDNBuild + }) + ] }, /** * App Node.js Builds @@ -280,7 +323,27 @@ const completeBuilds = [ typescriptPluginUMD, json(), commonjs(), - terser() + terser({ + format: { + comments: false + } + }), + alias({ + entries: [ + { + find: '@firebase/app', + replacement: '@firebase/app-exp' + }, + { + find: `@firebase/performance`, + replacement: `@firebase/performance-exp` + }, + { + find: '@firebase/installations', + replacement: '@firebase/installations-exp' + } + ] + }) ] }, /** @@ -312,7 +375,27 @@ const completeBuilds = [ preferConst: true }), commonjs(), - terser() + terser({ + format: { + comments: false + } + }), + alias({ + entries: [ + { + find: '@firebase/app', + replacement: '@firebase/app-exp' + }, + { + find: `@firebase/performance`, + replacement: `@firebase/performance-exp` + }, + { + find: '@firebase/installations', + replacement: '@firebase/installations-exp' + } + ] + }) ] } ]; diff --git a/packages-exp/firebase-exp/package.json b/packages-exp/firebase-exp/package.json index 5a91f412f19..1829e386a60 100644 --- a/packages-exp/firebase-exp/package.json +++ b/packages-exp/firebase-exp/package.json @@ -1,6 +1,6 @@ { "name": "firebase-exp", - "version": "9.0.0-beta.1", + "version": "9.0.0-beta.2", "private": true, "description": "Firebase JavaScript library for web and Node.js", "author": "Firebase (https://firebase.google.com/)", @@ -52,10 +52,6 @@ "node": "./functions/dist/index.cjs.js", "default": "./functions/dist/index.esm.js" }, - "./installations": { - "node": "./installations/dist/index.cjs.js", - "default": "./installations/dist/index.esm.js" - }, "./messaging": { "node": "./messaging/dist/index.cjs.js", "default": "./messaging/dist/index.esm.js" @@ -96,10 +92,6 @@ "node": "./compat/functions/dist/index.cjs.js", "default": "./compat/functions/dist/index.esm.js" }, - "./compat/installations": { - "node": "./compat/installations/dist/index.cjs.js", - "default": "./compat/installations/dist/index.esm.js" - }, "./compat/messaging": { "node": "./compat/messaging/dist/index.cjs.js", "default": "./compat/messaging/dist/index.esm.js" @@ -137,11 +129,11 @@ "@firebase/app-compat": "0.0.900", "@firebase/auth-exp": "0.0.900", "@firebase/auth-compat": "0.0.900", - "@firebase/database": "0.9.10", + "@firebase/database": "0.10.1", "@firebase/functions-exp": "0.0.900", "@firebase/functions-compat": "0.0.900", - "@firebase/firestore": "2.2.4", - "@firebase/storage": "0.5.0", + "@firebase/firestore": "2.3.0", + "@firebase/storage": "0.5.2", "@firebase/performance-exp": "0.0.900", "@firebase/performance-compat": "0.0.900", "@firebase/remote-config-exp": "0.0.900", diff --git a/packages-exp/functions-compat/package.json b/packages-exp/functions-compat/package.json index 035c55fc3ce..1993d8ec9d8 100644 --- a/packages-exp/functions-compat/package.json +++ b/packages-exp/functions-compat/package.json @@ -47,11 +47,11 @@ }, "typings": "dist/src/index.d.ts", "dependencies": { - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "@firebase/functions-exp": "0.0.900", "@firebase/functions-types": "0.4.0", "@firebase/messaging-types": "0.5.0", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "tslib": "^2.1.0" }, "nyc": { diff --git a/packages-exp/functions-exp/package.json b/packages-exp/functions-exp/package.json index 8df04cd000d..7aea455d95a 100644 --- a/packages-exp/functions-exp/package.json +++ b/packages-exp/functions-exp/package.json @@ -50,9 +50,9 @@ }, "typings": "dist/functions-exp-public.d.ts", "dependencies": { - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "@firebase/messaging-types": "0.5.0", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "node-fetch": "2.6.1", "tslib": "^2.1.0" }, diff --git a/packages-exp/functions-exp/src/context.ts b/packages-exp/functions-exp/src/context.ts index 7ccd5f8823a..75090447037 100644 --- a/packages-exp/functions-exp/src/context.ts +++ b/packages-exp/functions-exp/src/context.ts @@ -1,3 +1,7 @@ +import { + FirebaseAuthInternal, + FirebaseAuthInternalName +} from '@firebase/auth-interop-types'; /** * @license * Copyright 2017 Google LLC @@ -18,10 +22,7 @@ import { FirebaseMessaging, FirebaseMessagingName } from '@firebase/messaging-types'; -import { - FirebaseAuthInternal, - FirebaseAuthInternalName -} from '@firebase/auth-interop-types'; + import { Provider } from '@firebase/component'; /** @@ -92,7 +93,7 @@ export class ContextProvider { } try { - return this.messaging.getToken(); + return await this.messaging.getToken(); } catch (e) { // We don't warn on this, because it usually means messaging isn't set up. // console.warn('Failed to retrieve instance id token.', e); diff --git a/packages-exp/installations-compat/package.json b/packages-exp/installations-compat/package.json index bed72cd4910..29a8c98ec62 100644 --- a/packages-exp/installations-compat/package.json +++ b/packages-exp/installations-compat/package.json @@ -51,8 +51,8 @@ "dependencies": { "@firebase/installations-exp": "0.0.900", "@firebase/installations-types": "0.3.4", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "idb": "3.0.2", "tslib": "^2.1.0" }, diff --git a/packages-exp/installations-exp/package.json b/packages-exp/installations-exp/package.json index 29fdad9f8e9..5a9a152b742 100644 --- a/packages-exp/installations-exp/package.json +++ b/packages-exp/installations-exp/package.json @@ -55,8 +55,8 @@ "@firebase/app-exp": "0.x" }, "dependencies": { - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "idb": "3.0.2", "tslib": "^2.1.0" }, diff --git a/packages-exp/messaging-compat/package.json b/packages-exp/messaging-compat/package.json index 07615dfde92..b173e90e257 100644 --- a/packages-exp/messaging-compat/package.json +++ b/packages-exp/messaging-compat/package.json @@ -32,9 +32,9 @@ }, "dependencies": { "@firebase/messaging-exp": "0.0.900", - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "@firebase/installations-exp": "0.0.900", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "tslib": "^2.1.0" }, "devDependencies": { diff --git a/packages-exp/messaging-compat/src/messaging-compat.ts b/packages-exp/messaging-compat/src/messaging-compat.ts index 547cf7b9167..16f11162652 100644 --- a/packages-exp/messaging-compat/src/messaging-compat.ts +++ b/packages-exp/messaging-compat/src/messaging-compat.ts @@ -24,8 +24,8 @@ import { MessagePayload, deleteToken, getToken, - onMessage, - onBackgroundMessage + onBackgroundMessage, + onMessage } from '@firebase/messaging-exp'; import { NextFn, Observer, Unsubscribe } from '@firebase/util'; @@ -46,6 +46,47 @@ export interface MessagingCompat { ): Unsubscribe; } +export function isSupported(): boolean { + if (self && 'ServiceWorkerGlobalScope' in self) { + // Running in ServiceWorker context + return isSwSupported(); + } else { + // Assume we are in the window context. + return isWindowSupported(); + } +} + +/** + * Checks to see if the required APIs exist. + */ +function isWindowSupported(): boolean { + return ( + 'indexedDB' in window && + indexedDB !== null && + navigator.cookieEnabled && + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window && + 'fetch' in window && + ServiceWorkerRegistration.prototype.hasOwnProperty('showNotification') && + PushSubscription.prototype.hasOwnProperty('getKey') + ); +} + +/** + * Checks to see if the required APIs exist within SW Context. + */ +function isSwSupported(): boolean { + return ( + 'indexedDB' in self && + indexedDB !== null && + 'PushManager' in self && + 'Notification' in self && + ServiceWorkerRegistration.prototype.hasOwnProperty('showNotification') && + PushSubscription.prototype.hasOwnProperty('getKey') + ); +} + export class MessagingCompatImpl implements MessagingCompat, _FirebaseService { swRegistration?: ServiceWorkerRegistration; vapidKey?: string; diff --git a/packages-exp/messaging-exp/package.json b/packages-exp/messaging-exp/package.json index e46a366b414..38999601c09 100644 --- a/packages-exp/messaging-exp/package.json +++ b/packages-exp/messaging-exp/package.json @@ -10,7 +10,8 @@ "typings": "dist/index.d.ts", "sw": "dist/index.sw.esm2017.js", "files": [ - "dist" + "dist", + "sw/package.json" ], "scripts": { "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", @@ -33,9 +34,9 @@ "@firebase/app-exp": "0.x" }, "dependencies": { - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "@firebase/installations-exp": "0.0.900", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "idb": "3.0.2", "tslib": "^2.1.0" }, diff --git a/packages-exp/messaging-exp/sw/package.json b/packages-exp/messaging-exp/sw/package.json index bc83f151ff8..10abf5a7d17 100644 --- a/packages-exp/messaging-exp/sw/package.json +++ b/packages-exp/messaging-exp/sw/package.json @@ -1,12 +1,7 @@ { "name": "@firebase/messaging-exp", - "private": true, - "version": "0.0.900", "description": "", "author": "Firebase (https://firebase.google.com/)", "module": "../dist/index.sw.esm2017.js", - "typings": "../dist/index.sw.d.ts", - "files": [ - "dist" - ] -} \ No newline at end of file + "typings": "../dist/index.sw.d.ts" +} diff --git a/packages-exp/performance-compat/package.json b/packages-exp/performance-compat/package.json index ee706aace02..920feae6def 100644 --- a/packages-exp/performance-compat/package.json +++ b/packages-exp/performance-compat/package.json @@ -32,9 +32,9 @@ "dependencies": { "@firebase/performance-exp": "0.0.900", "@firebase/performance-types": "0.0.13", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "@firebase/logger": "0.2.6", - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "devDependencies": { diff --git a/packages-exp/performance-exp/package.json b/packages-exp/performance-exp/package.json index 1e2fa463eb2..ac384a21e78 100644 --- a/packages-exp/performance-exp/package.json +++ b/packages-exp/performance-exp/package.json @@ -33,8 +33,8 @@ "dependencies": { "@firebase/logger": "0.2.6", "@firebase/installations-exp": "0.0.900", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages-exp/remote-config-compat/package.json b/packages-exp/remote-config-compat/package.json index f459fe4a6a8..f68b0e4fa52 100644 --- a/packages-exp/remote-config-compat/package.json +++ b/packages-exp/remote-config-compat/package.json @@ -31,9 +31,9 @@ "dependencies": { "@firebase/remote-config-exp": "0.0.900", "@firebase/remote-config-types": "0.1.9", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "@firebase/logger": "0.2.6", - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "devDependencies": { diff --git a/packages-exp/remote-config-exp/package.json b/packages-exp/remote-config-exp/package.json index a7ee309331e..9354e8fb169 100644 --- a/packages-exp/remote-config-exp/package.json +++ b/packages-exp/remote-config-exp/package.json @@ -35,8 +35,8 @@ "dependencies": { "@firebase/installations-exp": "0.0.900", "@firebase/logger": "0.2.6", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages-exp/remote-config-exp/rollup.config.release.js b/packages-exp/remote-config-exp/rollup.config.release.js index 1e3b338e4b5..7e38601f87c 100644 --- a/packages-exp/remote-config-exp/rollup.config.release.js +++ b/packages-exp/remote-config-exp/rollup.config.release.js @@ -38,7 +38,7 @@ const es5Builds = es5BuildsNoPlugin.map(build => ({ ...build, plugins: es5BuildPlugins, treeshake: { - moduleSideEffects: false + moduleSideEffects: (id, external) => id === '@firebase/installations' } })); @@ -66,7 +66,7 @@ const es2017Builds = es2017BuildsNoPlugin.map(build => ({ ...build, plugins: es2017BuildPlugins, treeshake: { - moduleSideEffects: false + moduleSideEffects: (id, external) => id === '@firebase/installations' } })); diff --git a/packages/analytics/CHANGELOG.md b/packages/analytics/CHANGELOG.md index a67f1c5a9e9..442aede47ff 100644 --- a/packages/analytics/CHANGELOG.md +++ b/packages/analytics/CHANGELOG.md @@ -1,5 +1,14 @@ # @firebase/analytics +## 0.6.10 + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/util@1.1.0 + - @firebase/installations@0.4.26 + ## 0.6.9 ### Patch Changes diff --git a/packages/analytics/package.json b/packages/analytics/package.json index be5933d3810..848a4c35dea 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/analytics", - "version": "0.6.9", + "version": "0.6.10", "description": "A analytics package for new firebase packages", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", @@ -27,15 +27,15 @@ }, "dependencies": { "@firebase/analytics-types": "0.4.0", - "@firebase/installations": "0.4.25", + "@firebase/installations": "0.4.26", "@firebase/logger": "0.2.6", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.6.20", + "@firebase/app": "0.6.22", "rollup": "2.35.1", "@rollup/plugin-commonjs": "17.1.0", "@rollup/plugin-json": "4.1.0", diff --git a/packages/app-check-interop-types/CHANGELOG.md b/packages/app-check-interop-types/CHANGELOG.md new file mode 100644 index 00000000000..d67cf45337a --- /dev/null +++ b/packages/app-check-interop-types/CHANGELOG.md @@ -0,0 +1,8 @@ +# @firebase/app-check-interop-types + +## 0.1.0 +### Minor Changes + + + +- [`81c131abe`](https://github.com/firebase/firebase-js-sdk/commit/81c131abea7001c5933156ff6b0f3925f16ff052) [#4860](https://github.com/firebase/firebase-js-sdk/pull/4860) - Release the Firebase App Check package. diff --git a/packages/app-check-interop-types/README.md b/packages/app-check-interop-types/README.md new file mode 100644 index 00000000000..c54a18385bf --- /dev/null +++ b/packages/app-check-interop-types/README.md @@ -0,0 +1,3 @@ +# @firebase/app-check-interop-types + +**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/app-check-interop-types/index.d.ts b/packages/app-check-interop-types/index.d.ts new file mode 100644 index 00000000000..d2309c1b4dd --- /dev/null +++ b/packages/app-check-interop-types/index.d.ts @@ -0,0 +1,47 @@ +/** + * @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 FirebaseAppCheckInternal { + // Get the current AttestationToken. Attaches to the most recent in-flight request if one + // is present. Returns null if no token is present and no token requests are in-flight. + getToken(forceRefresh?: boolean): Promise; + + // Registers a listener to changes in the token state. There can be more than one listener + // registered at the same time for one or more FirebaseAppAttestation instances. The + // listeners call back on the UI thread whenever the current token associated with this + // FirebaseAppAttestation changes. + addTokenListener(listener: AppCheckTokenListener): void; + + // Unregisters a listener to changes in the token state. + removeTokenListener(listener: AppCheckTokenListener): void; +} + +type AppCheckTokenListener = (token: AppCheckTokenResult) => void; + +// If the error field is defined, the token field will be populated with a dummy token +interface AppCheckTokenResult { + readonly token: string; + readonly error?: Error; +} + +export type AppCheckInternalComponentName = 'app-check-internal'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'app-check-internal': FirebaseAppCheckInternal; + } +} diff --git a/packages/app-check-interop-types/package.json b/packages/app-check-interop-types/package.json new file mode 100644 index 00000000000..13b071329f1 --- /dev/null +++ b/packages/app-check-interop-types/package.json @@ -0,0 +1,25 @@ +{ + "name": "@firebase/app-check-interop-types", + "version": "0.1.0", + "description": "@firebase/app-check-interop-types Types", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "test": "tsc", + "test:ci": "node ../../scripts/run_tests_in_ci.js" + }, + "files": [ + "index.d.ts" + ], + "repository": { + "directory": "packages/app-check-interop-types", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "devDependencies": { + "typescript": "4.2.2" + } +} diff --git a/packages/app-check-interop-types/tsconfig.json b/packages/app-check-interop-types/tsconfig.json new file mode 100644 index 00000000000..9a785433d90 --- /dev/null +++ b/packages/app-check-interop-types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": [ + "dist/**/*" + ] +} diff --git a/packages/app-check-types/CHANGELOG.md b/packages/app-check-types/CHANGELOG.md new file mode 100644 index 00000000000..6fb0a49b873 --- /dev/null +++ b/packages/app-check-types/CHANGELOG.md @@ -0,0 +1,8 @@ +# @firebase/app-check-types + +## 0.1.0 +### Minor Changes + + + +- [`81c131abe`](https://github.com/firebase/firebase-js-sdk/commit/81c131abea7001c5933156ff6b0f3925f16ff052) [#4860](https://github.com/firebase/firebase-js-sdk/pull/4860) - Release the Firebase App Check package. diff --git a/packages/app-check-types/README.md b/packages/app-check-types/README.md new file mode 100644 index 00000000000..d9a53941457 --- /dev/null +++ b/packages/app-check-types/README.md @@ -0,0 +1,3 @@ +# @firebase/app-check-types + +**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/app-check-types/index.d.ts b/packages/app-check-types/index.d.ts new file mode 100644 index 00000000000..16fc46f373f --- /dev/null +++ b/packages/app-check-types/index.d.ts @@ -0,0 +1,72 @@ +/** + * @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 FirebaseAppCheck { + /** + * Activate AppCheck + * @param siteKeyOrProvider - reCAPTCHA sitekey or custom token provider + * @param isTokenAutoRefreshEnabled - If true, enables SDK to automatically + * refresh AppCheck token as needed. If undefined, the value will default + * to the value of `app.automaticDataCollectionEnabled`. That property + * defaults to false and can be set in the app config. + */ + activate( + siteKeyOrProvider: string | AppCheckProvider, + isTokenAutoRefreshEnabled?: boolean + ): void; + + /** + * + * @param isTokenAutoRefreshEnabled - If true, the SDK automatically + * refreshes App Check tokens as needed. This overrides any value set + * during `activate()`. + */ + setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled: boolean): void; +} + +/** + * An App Check provider. This can be either the built-in reCAPTCHA provider + * or a custom provider. For more on custom providers, see + * https://firebase.google.com/docs/app-check/web-custom-provider + */ +interface AppCheckProvider { + /** + * Returns an AppCheck token. + */ + getToken(): Promise; +} + +/** + * The token returned from an `AppCheckProvider`. + */ +interface AppCheckToken { + /** + * The token string in JWT format. + */ + readonly token: string; + /** + * The local timestamp after which the token will expire. + */ + readonly expireTimeMillis: number; +} + +export type AppCheckComponentName = 'appCheck'; +declare module '@firebase/component' { + interface NameServiceMapping { + 'appCheck': FirebaseAppCheck; + } +} diff --git a/packages/app-check-types/package.json b/packages/app-check-types/package.json new file mode 100644 index 00000000000..7136b0b829b --- /dev/null +++ b/packages/app-check-types/package.json @@ -0,0 +1,25 @@ +{ + "name": "@firebase/app-check-types", + "version": "0.1.0", + "description": "@firebase/app-check Types", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "test": "tsc", + "test:ci": "node ../../scripts/run_tests_in_ci.js" + }, + "files": [ + "index.d.ts" + ], + "repository": { + "directory": "packages/app-check-types", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "devDependencies": { + "typescript": "4.2.2" + } +} diff --git a/packages/app-check-types/tsconfig.json b/packages/app-check-types/tsconfig.json new file mode 100644 index 00000000000..9a785433d90 --- /dev/null +++ b/packages/app-check-types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": [ + "dist/**/*" + ] +} diff --git a/packages/app-check/.eslintrc.js b/packages/app-check/.eslintrc.js new file mode 100644 index 00000000000..ca80aa0f69a --- /dev/null +++ b/packages/app-check/.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/app-check/CHANGELOG.md b/packages/app-check/CHANGELOG.md new file mode 100644 index 00000000000..5477f8a7641 --- /dev/null +++ b/packages/app-check/CHANGELOG.md @@ -0,0 +1,27 @@ +# @firebase/app-check + +## 0.1.1 + +### Patch Changes + +- [`60e834739`](https://github.com/firebase/firebase-js-sdk/commit/60e83473940e60f8390b1b0f97cf45a1733f66f0) [#4897](https://github.com/firebase/firebase-js-sdk/pull/4897) - Make App Check initialization explicit, to prevent unexpected errors for users who do not intend to use App Check. + +## 0.1.0 + +### Minor Changes + +- [`81c131abe`](https://github.com/firebase/firebase-js-sdk/commit/81c131abea7001c5933156ff6b0f3925f16ff052) [#4860](https://github.com/firebase/firebase-js-sdk/pull/4860) - Release the Firebase App Check package. + +### Patch Changes + +- Updated dependencies [[`81c131abe`](https://github.com/firebase/firebase-js-sdk/commit/81c131abea7001c5933156ff6b0f3925f16ff052)]: + - @firebase/app-check-interop-types@0.1.0 + - @firebase/app-check-types@0.1.0 + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/util@1.1.0 diff --git a/packages/app-check/README.md b/packages/app-check/README.md new file mode 100644 index 00000000000..f9d65043ed9 --- /dev/null +++ b/packages/app-check/README.md @@ -0,0 +1,3 @@ +# @firebase/app-check + +App Check SDK \ No newline at end of file diff --git a/packages/app-check/karma.conf.js b/packages/app-check/karma.conf.js new file mode 100644 index 00000000000..73a1240be3f --- /dev/null +++ b/packages/app-check/karma.conf.js @@ -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. + */ + +const karmaBase = require('../../config/karma.base'); + +const files = [`src/**/*.test.ts`]; + +module.exports = function (config) { + const karmaConfig = { + ...karmaBase, + // files to load into karma + files, + // 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/app-check/package.json b/packages/app-check/package.json new file mode 100644 index 00000000000..ad9694734af --- /dev/null +++ b/packages/app-check/package.json @@ -0,0 +1,61 @@ +{ + "name": "@firebase/app-check", + "version": "0.1.1", + "description": "The App Check component of the Firebase JS SDK", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.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-check --include-dependencies build", + "dev": "rollup -c -w", + "test": "yarn type-check && yarn test:browser", + "test:ci": "node ../../scripts/run_tests_in_ci.js", + "test:browser": "karma start --single-run", + "test:browser:debug": "karma start --browsers Chrome --auto-watch", + "type-check": "tsc -p . --noEmit", + "prepare": "yarn build" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + }, + "dependencies": { + "@firebase/app-check-types": "0.1.0", + "@firebase/app-check-interop-types": "0.1.0", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", + "@firebase/logger": "0.2.6", + "tslib": "^2.1.0" + }, + "license": "Apache-2.0", + "devDependencies": { + "@firebase/app": "0.6.22", + "rollup": "2.35.1", + "@rollup/plugin-json": "4.1.0", + "rollup-plugin-typescript2": "0.29.0", + "typescript": "4.2.2" + }, + "repository": { + "directory": "packages/app-check", + "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/app-check/rollup.config.js b/packages/app-check/rollup.config.js new file mode 100644 index 00000000000..1a63672e7af --- /dev/null +++ b/packages/app-check/rollup.config.js @@ -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 json from '@rollup/plugin-json'; +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 + }), + json() +]; + +const es5Builds = [ + /** + * Browser Builds + */ + { + input: 'src/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}/`)) + } +]; + +/** + * 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/app-check/src/api.test.ts b/packages/app-check/src/api.test.ts new file mode 100644 index 00000000000..89cc8fe06c4 --- /dev/null +++ b/packages/app-check/src/api.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 '../test/setup'; +import { expect } from 'chai'; +import { stub } from 'sinon'; +import { activate, setTokenAutoRefreshEnabled } from './api'; +import { + FAKE_SITE_KEY, + getFakeApp, + getFakeCustomTokenProvider +} from '../test/util'; +import { getState } from './state'; +import * as reCAPTCHA from './recaptcha'; +import { FirebaseApp } from '@firebase/app-types'; + +describe('api', () => { + describe('activate()', () => { + let app: FirebaseApp; + + beforeEach(() => { + app = getFakeApp(); + }); + + it('sets activated to true', () => { + expect(getState(app).activated).to.equal(false); + activate(app, FAKE_SITE_KEY); + expect(getState(app).activated).to.equal(true); + }); + + it('isTokenAutoRefreshEnabled value defaults to global setting', () => { + app = getFakeApp({ automaticDataCollectionEnabled: false }); + activate(app, FAKE_SITE_KEY); + expect(getState(app).isTokenAutoRefreshEnabled).to.equal(false); + }); + + it('sets isTokenAutoRefreshEnabled correctly, overriding global setting', () => { + app = getFakeApp({ automaticDataCollectionEnabled: false }); + activate(app, FAKE_SITE_KEY, true); + expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true); + }); + + it('can only be called once', () => { + activate(app, FAKE_SITE_KEY); + expect(() => activate(app, FAKE_SITE_KEY)).to.throw( + /AppCheck can only be activated once/ + ); + }); + + it('initialize reCAPTCHA when a sitekey is provided', () => { + const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize').returns( + Promise.resolve({} as any) + ); + activate(app, FAKE_SITE_KEY); + expect(initReCAPTCHAStub).to.have.been.calledWithExactly( + app, + FAKE_SITE_KEY + ); + }); + + it('does NOT initialize reCAPTCHA when a custom token provider is provided', () => { + const fakeCustomTokenProvider = getFakeCustomTokenProvider(); + const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize'); + activate(app, fakeCustomTokenProvider); + expect(getState(app).customProvider).to.equal(fakeCustomTokenProvider); + expect(initReCAPTCHAStub).to.have.not.been.called; + }); + }); + describe('setTokenAutoRefreshEnabled()', () => { + it('sets isTokenAutoRefreshEnabled correctly', () => { + const app = getFakeApp({ automaticDataCollectionEnabled: false }); + setTokenAutoRefreshEnabled(app, true); + expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true); + }); + }); +}); diff --git a/packages/app-check/src/api.ts b/packages/app-check/src/api.ts new file mode 100644 index 00000000000..7913a114127 --- /dev/null +++ b/packages/app-check/src/api.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. + */ + +import { AppCheckProvider } from '@firebase/app-check-types'; +import { FirebaseApp } from '@firebase/app-types'; +import { ERROR_FACTORY, AppCheckError } from './errors'; +import { initialize as initializeRecaptcha } from './recaptcha'; +import { getState, setState, AppCheckState } from './state'; + +/** + * + * @param app + * @param siteKeyOrProvider - optional custom attestation provider + * or reCAPTCHA siteKey + * @param isTokenAutoRefreshEnabled - if true, enables auto refresh + * of appCheck token. + */ +export function activate( + app: FirebaseApp, + siteKeyOrProvider: string | AppCheckProvider, + isTokenAutoRefreshEnabled?: boolean +): void { + const state = getState(app); + if (state.activated) { + throw ERROR_FACTORY.create(AppCheckError.ALREADY_ACTIVATED, { + appName: app.name + }); + } + + const newState: AppCheckState = { ...state, activated: true }; + if (typeof siteKeyOrProvider === 'string') { + newState.siteKey = siteKeyOrProvider; + } else { + newState.customProvider = siteKeyOrProvider; + } + + // Use value of global `automaticDataCollectionEnabled` (which + // itself defaults to false if not specified in config) if + // `isTokenAutoRefreshEnabled` param was not provided by user. + newState.isTokenAutoRefreshEnabled = + isTokenAutoRefreshEnabled === undefined + ? app.automaticDataCollectionEnabled + : isTokenAutoRefreshEnabled; + + setState(app, newState); + + // initialize reCAPTCHA if siteKey is provided + if (newState.siteKey) { + initializeRecaptcha(app, newState.siteKey).catch(() => { + /* we don't care about the initialization result in activate() */ + }); + } +} + +export function setTokenAutoRefreshEnabled( + app: FirebaseApp, + isTokenAutoRefreshEnabled: boolean +): void { + const state = getState(app); + // This will exist if any product libraries have called + // `addTokenListener()` + if (state.tokenRefresher) { + if (isTokenAutoRefreshEnabled === true) { + state.tokenRefresher.start(); + } else { + state.tokenRefresher.stop(); + } + } + setState(app, { ...state, isTokenAutoRefreshEnabled }); +} diff --git a/packages/app-check/src/client.test.ts b/packages/app-check/src/client.test.ts new file mode 100644 index 00000000000..78a06f90799 --- /dev/null +++ b/packages/app-check/src/client.test.ts @@ -0,0 +1,197 @@ +/** + * @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 '../test/setup'; +import { expect } from 'chai'; +import { stub, SinonStub, useFakeTimers } from 'sinon'; +import { FirebaseApp } from '@firebase/app-types'; +import { getFakeApp, getFakePlatformLoggingProvider } from '../test/util'; +import { getExchangeRecaptchaTokenRequest, exchangeToken } from './client'; +import { FirebaseError } from '@firebase/util'; +import { ERROR_FACTORY, AppCheckError } from './errors'; +import { BASE_ENDPOINT } from './constants'; + +describe('client', () => { + let app: FirebaseApp; + let fetchStub: SinonStub<[RequestInfo, RequestInit?], Promise>; + beforeEach(() => { + app = getFakeApp(); + fetchStub = stub(window, 'fetch').returns( + Promise.resolve(new Response('{}')) + ); + }); + + it('creates exchange recaptcha token request correctly', () => { + const request = getExchangeRecaptchaTokenRequest( + app, + 'fake-recaptcha-token' + ); + const { projectId, appId, apiKey } = app.options; + + expect(request).to.deep.equal({ + url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:exchangeRecaptchaToken?key=${apiKey}`, + body: { + // eslint-disable-next-line camelcase + recaptcha_token: 'fake-recaptcha-token' + } + }); + }); + + it('returns a AppCheck token', async () => { + useFakeTimers(); + fetchStub.returns( + Promise.resolve({ + status: 200, + json: async () => ({ + attestationToken: 'fake-appcheck-token', + ttl: '3.600s' + }) + } as Response) + ); + + const response = await exchangeToken( + getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getFakePlatformLoggingProvider('a/1.2.3 fire-app-check/2.3.4') + ); + + expect( + (fetchStub.args[0][1]?.['headers'] as any)['X-Firebase-Client'] + ).to.equal('a/1.2.3 fire-app-check/2.3.4'); + + expect(response).to.deep.equal({ + token: 'fake-appcheck-token', + expireTimeMillis: 3600, + issuedAtTimeMillis: 0 + }); + }); + + it('throws when there is a network error', async () => { + const originalError = new TypeError('Network request failed'); + fetchStub.returns(Promise.reject(originalError)); + const firebaseError = ERROR_FACTORY.create( + AppCheckError.FETCH_NETWORK_ERROR, + { + originalErrorMessage: originalError.message + } + ); + + try { + await exchangeToken( + getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getFakePlatformLoggingProvider() + ); + } catch (e) { + expect(e).instanceOf(FirebaseError); + expect(e).has.property('message', firebaseError.message); + expect(e).has.nested.property( + 'customData.originalErrorMessage', + 'Network request failed' + ); + } + }); + + it('throws when response status is not 200', async () => { + fetchStub.returns( + Promise.resolve({ + status: 500 + } as Response) + ); + + const firebaseError = ERROR_FACTORY.create( + AppCheckError.FETCH_STATUS_ERROR, + { + httpStatus: 500 + } + ); + + try { + await exchangeToken( + getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getFakePlatformLoggingProvider() + ); + } catch (e) { + expect(e).instanceOf(FirebaseError); + expect(e).has.property('message', firebaseError.message); + expect(e).has.nested.property('customData.httpStatus', 500); + } + }); + + it('throws if the response body is not json', async () => { + const originalError = new SyntaxError('invalid JSON string'); + fetchStub.returns( + Promise.resolve({ + status: 200, + json: () => Promise.reject(originalError) + } as Response) + ); + + const firebaseError = ERROR_FACTORY.create( + AppCheckError.FETCH_PARSE_ERROR, + { + originalErrorMessage: originalError.message + } + ); + + try { + await exchangeToken( + getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getFakePlatformLoggingProvider() + ); + } catch (e) { + expect(e).instanceOf(FirebaseError); + expect(e).has.property('message', firebaseError.message); + expect(e).has.nested.property( + 'customData.originalErrorMessage', + originalError.message + ); + } + }); + + it('throws if timeToLive field is not a number', async () => { + fetchStub.returns( + Promise.resolve({ + status: 200, + json: () => + Promise.resolve({ + attestationToken: 'fake-appcheck-token', + ttl: 'NAN' + }) + } as Response) + ); + + const firebaseError = ERROR_FACTORY.create( + AppCheckError.FETCH_PARSE_ERROR, + { + originalErrorMessage: `ttl field (timeToLive) is not in standard Protobuf Duration format: NAN` + } + ); + + try { + await exchangeToken( + getExchangeRecaptchaTokenRequest(app, 'fake-custom-token'), + getFakePlatformLoggingProvider() + ); + } catch (e) { + expect(e).instanceOf(FirebaseError); + expect(e).has.property('message', firebaseError.message); + expect(e).has.nested.property( + 'customData.originalErrorMessage', + `ttl field (timeToLive) is not in standard Protobuf Duration format: NAN` + ); + } + }); +}); diff --git a/packages/app-check/src/client.ts b/packages/app-check/src/client.ts new file mode 100644 index 00000000000..be9b0c52d49 --- /dev/null +++ b/packages/app-check/src/client.ts @@ -0,0 +1,134 @@ +/** + * @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 { + BASE_ENDPOINT, + EXCHANGE_DEBUG_TOKEN_METHOD, + EXCHANGE_RECAPTCHA_TOKEN_METHOD +} from './constants'; +import { FirebaseApp } from '@firebase/app-types'; +import { ERROR_FACTORY, AppCheckError } from './errors'; +import { Provider } from '@firebase/component'; +import { AppCheckTokenInternal } from './state'; + +/** + * Response JSON returned from AppCheck server endpoint. + */ +interface AppCheckResponse { + attestationToken: string; + // timeToLive + ttl: string; +} + +interface AppCheckRequest { + url: string; + body: { [key: string]: string }; +} + +export async function exchangeToken( + { url, body }: AppCheckRequest, + platformLoggerProvider: Provider<'platform-logger'> +): Promise { + const headers: HeadersInit = { + 'Content-Type': 'application/json' + }; + // If platform logger exists, add the platform info string to the header. + const platformLogger = platformLoggerProvider.getImmediate({ + optional: true + }); + if (platformLogger) { + headers['X-Firebase-Client'] = platformLogger.getPlatformInfoString(); + } + const options: RequestInit = { + method: 'POST', + body: JSON.stringify(body), + headers + }; + let response; + try { + response = await fetch(url, options); + } catch (originalError) { + throw ERROR_FACTORY.create(AppCheckError.FETCH_NETWORK_ERROR, { + originalErrorMessage: originalError.message + }); + } + + if (response.status !== 200) { + throw ERROR_FACTORY.create(AppCheckError.FETCH_STATUS_ERROR, { + httpStatus: response.status + }); + } + + let responseBody: AppCheckResponse; + try { + // JSON parsing throws SyntaxError if the response body isn't a JSON string. + responseBody = await response.json(); + } catch (originalError) { + throw ERROR_FACTORY.create(AppCheckError.FETCH_PARSE_ERROR, { + originalErrorMessage: originalError.message + }); + } + + // Protobuf duration format. + // https://developers.google.com/protocol-buffers/docs/reference/java/com/google/protobuf/Duration + const match = responseBody.ttl.match(/^([\d.]+)(s)$/); + if (!match || !match[2] || isNaN(Number(match[1]))) { + throw ERROR_FACTORY.create(AppCheckError.FETCH_PARSE_ERROR, { + originalErrorMessage: + `ttl field (timeToLive) is not in standard Protobuf Duration ` + + `format: ${responseBody.ttl}` + }); + } + const timeToLiveAsNumber = Number(match[1]) * 1000; + + const now = Date.now(); + return { + token: responseBody.attestationToken, + expireTimeMillis: now + timeToLiveAsNumber, + issuedAtTimeMillis: now + }; +} + +export function getExchangeRecaptchaTokenRequest( + app: FirebaseApp, + reCAPTCHAToken: string +): AppCheckRequest { + const { projectId, appId, apiKey } = app.options; + + return { + url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:${EXCHANGE_RECAPTCHA_TOKEN_METHOD}?key=${apiKey}`, + body: { + // eslint-disable-next-line + recaptcha_token: reCAPTCHAToken + } + }; +} + +export function getExchangeDebugTokenRequest( + app: FirebaseApp, + debugToken: string +): AppCheckRequest { + const { projectId, appId, apiKey } = app.options; + + return { + url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:${EXCHANGE_DEBUG_TOKEN_METHOD}?key=${apiKey}`, + body: { + // eslint-disable-next-line + debug_token: debugToken + } + }; +} diff --git a/packages/app-check/src/constants.ts b/packages/app-check/src/constants.ts new file mode 100644 index 00000000000..56cdd623427 --- /dev/null +++ b/packages/app-check/src/constants.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. + */ +export const BASE_ENDPOINT = + 'https://content-firebaseappcheck.googleapis.com/v1beta'; + +export const EXCHANGE_RECAPTCHA_TOKEN_METHOD = 'exchangeRecaptchaToken'; +export const EXCHANGE_DEBUG_TOKEN_METHOD = 'exchangeDebugToken'; + +export const TOKEN_REFRESH_TIME = { + /** + * The offset time before token natural expiration to run the refresh. + * This is currently 5 minutes. + */ + OFFSET_DURATION: 5 * 60 * 1000, + /** + * This is the first retrial wait after an error. This is currently + * 30 seconds. + */ + RETRIAL_MIN_WAIT: 30 * 1000, + /** + * This is the maximum retrial wait, currently 16 minutes. + */ + RETRIAL_MAX_WAIT: 16 * 60 * 1000 +}; diff --git a/packages/app-check/src/debug.test.ts b/packages/app-check/src/debug.test.ts new file mode 100644 index 00000000000..7dc86287b89 --- /dev/null +++ b/packages/app-check/src/debug.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 '../test/setup'; +import { expect } from 'chai'; +import { stub } from 'sinon'; +import * as storage from './storage'; +import * as indexeddb from './indexeddb'; +import { clearState, getDebugState } from './state'; +import { initializeDebugMode } from './debug'; + +describe('debug mode', () => { + afterEach(() => { + clearState(); + // reset the global variable for debug mode + self.FIREBASE_APPCHECK_DEBUG_TOKEN = undefined; + }); + + it('enables debug mode if self.FIREBASE_APPCHECK_DEBUG_TOKEN is set to a string', async () => { + self.FIREBASE_APPCHECK_DEBUG_TOKEN = 'my-debug-token'; + initializeDebugMode(); + const debugState = getDebugState(); + + expect(debugState.enabled).to.be.true; + await expect(debugState.token?.promise).to.eventually.equal( + 'my-debug-token' + ); + }); + + it('generates a debug token if self.FIREBASE_APPCHECK_DEBUG_TOKEN is set to true', async () => { + stub(storage, 'readOrCreateDebugTokenFromStorage').returns( + Promise.resolve('my-debug-token') + ); + + self.FIREBASE_APPCHECK_DEBUG_TOKEN = true; + initializeDebugMode(); + const debugState = getDebugState(); + + expect(debugState.enabled).to.be.true; + await expect(debugState.token?.promise).to.eventually.equal( + 'my-debug-token' + ); + }); + + it('saves the generated debug token to indexedDB', async () => { + const saveToIndexedDBStub = stub( + indexeddb, + 'writeDebugTokenToIndexedDB' + ).callsFake(() => Promise.resolve()); + + self.FIREBASE_APPCHECK_DEBUG_TOKEN = true; + initializeDebugMode(); + + await getDebugState().token?.promise; + expect(saveToIndexedDBStub).to.have.been.called; + }); + + it('uses the cached debug token when it exists if self.FIREBASE_APPCHECK_DEBUG_TOKEN is set to true', async () => { + stub(indexeddb, 'readDebugTokenFromIndexedDB').returns( + Promise.resolve('cached-debug-token') + ); + + self.FIREBASE_APPCHECK_DEBUG_TOKEN = true; + initializeDebugMode(); + + const debugState = getDebugState(); + expect(debugState.enabled).to.be.true; + await expect(debugState.token?.promise).to.eventually.equal( + 'cached-debug-token' + ); + }); +}); diff --git a/packages/app-check/src/debug.ts b/packages/app-check/src/debug.ts new file mode 100644 index 00000000000..da6cffbfcc4 --- /dev/null +++ b/packages/app-check/src/debug.ts @@ -0,0 +1,66 @@ +/** + * @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 { getDebugState } from './state'; +import { readOrCreateDebugTokenFromStorage } from './storage'; +import { Deferred, getGlobal } from '@firebase/util'; + +declare global { + // var must be used for global scopes + // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#type-checking-for-globalthis + // eslint-disable-next-line no-var + var FIREBASE_APPCHECK_DEBUG_TOKEN: boolean | string | undefined; +} + +export function isDebugMode(): boolean { + const debugState = getDebugState(); + return debugState.enabled; +} + +export async function getDebugToken(): Promise { + const state = getDebugState(); + + if (state.enabled && state.token) { + return state.token.promise; + } else { + // should not happen! + throw Error(` + Can't get debug token in production mode. + `); + } +} + +export function initializeDebugMode(): void { + const globals = getGlobal(); + if ( + typeof globals.FIREBASE_APPCHECK_DEBUG_TOKEN !== 'string' && + globals.FIREBASE_APPCHECK_DEBUG_TOKEN !== true + ) { + return; + } + + const debugState = getDebugState(); + debugState.enabled = true; + const deferredToken = new Deferred(); + debugState.token = deferredToken; + + if (typeof globals.FIREBASE_APPCHECK_DEBUG_TOKEN === 'string') { + deferredToken.resolve(globals.FIREBASE_APPCHECK_DEBUG_TOKEN); + } else { + deferredToken.resolve(readOrCreateDebugTokenFromStorage()); + } +} diff --git a/packages/app-check/src/errors.ts b/packages/app-check/src/errors.ts new file mode 100644 index 00000000000..9ed9aa7b1b2 --- /dev/null +++ b/packages/app-check/src/errors.ts @@ -0,0 +1,72 @@ +/** + * @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 { ErrorFactory, ErrorMap } from '@firebase/util'; + +export const enum AppCheckError { + ALREADY_ACTIVATED = 'already-activated', + USE_BEFORE_ACTIVATION = 'use-before-activation', + FETCH_NETWORK_ERROR = 'fetch-network-error', + FETCH_PARSE_ERROR = 'fetch-parse-error', + FETCH_STATUS_ERROR = 'fetch-status-error', + STORAGE_OPEN = 'storage-open', + STORAGE_GET = 'storage-get', + STORAGE_WRITE = 'storage-set', + RECAPTCHA_ERROR = 'recaptcha-error' +} + +const ERRORS: ErrorMap = { + [AppCheckError.ALREADY_ACTIVATED]: + 'You are trying to activate AppCheck for FirebaseApp {$appName}, ' + + 'while it is already activated. ' + + 'AppCheck can only be activated once.', + [AppCheckError.USE_BEFORE_ACTIVATION]: + 'AppCheck is being used before activate() is called for FirebaseApp {$appName}. ' + + 'Please make sure you call activate() before instantiating other Firebase services.', + [AppCheckError.FETCH_NETWORK_ERROR]: + 'Fetch failed to connect to a network. Check Internet connection. ' + + 'Original error: {$originalErrorMessage}.', + [AppCheckError.FETCH_PARSE_ERROR]: + 'Fetch client could not parse response.' + + ' Original error: {$originalErrorMessage}.', + [AppCheckError.FETCH_STATUS_ERROR]: + 'Fetch server returned an HTTP error status. HTTP status: {$httpStatus}.', + [AppCheckError.STORAGE_OPEN]: + 'Error thrown when opening storage. Original error: {$originalErrorMessage}.', + [AppCheckError.STORAGE_GET]: + 'Error thrown when reading from storage. Original error: {$originalErrorMessage}.', + [AppCheckError.STORAGE_WRITE]: + 'Error thrown when writing to storage. Original error: {$originalErrorMessage}.', + [AppCheckError.RECAPTCHA_ERROR]: 'ReCAPTCHA error.' +}; + +interface ErrorParams { + [AppCheckError.ALREADY_ACTIVATED]: { appName: string }; + [AppCheckError.USE_BEFORE_ACTIVATION]: { appName: string }; + [AppCheckError.FETCH_NETWORK_ERROR]: { originalErrorMessage: string }; + [AppCheckError.FETCH_PARSE_ERROR]: { originalErrorMessage: string }; + [AppCheckError.FETCH_STATUS_ERROR]: { httpStatus: number }; + [AppCheckError.STORAGE_OPEN]: { originalErrorMessage?: string }; + [AppCheckError.STORAGE_GET]: { originalErrorMessage?: string }; + [AppCheckError.STORAGE_WRITE]: { originalErrorMessage?: string }; +} + +export const ERROR_FACTORY = new ErrorFactory( + 'appCheck', + 'AppCheck', + ERRORS +); diff --git a/packages/app-check/src/factory.ts b/packages/app-check/src/factory.ts new file mode 100644 index 00000000000..4789036b881 --- /dev/null +++ b/packages/app-check/src/factory.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 { FirebaseAppCheck, AppCheckProvider } from '@firebase/app-check-types'; +import { activate, setTokenAutoRefreshEnabled } from './api'; +import { FirebaseApp } from '@firebase/app-types'; +import { FirebaseAppCheckInternal } from '@firebase/app-check-interop-types'; +import { + getToken, + addTokenListener, + removeTokenListener +} from './internal-api'; +import { Provider } from '@firebase/component'; + +export function factory(app: FirebaseApp): FirebaseAppCheck { + return { + activate: ( + siteKeyOrProvider: string | AppCheckProvider, + isTokenAutoRefreshEnabled?: boolean + ) => activate(app, siteKeyOrProvider, isTokenAutoRefreshEnabled), + setTokenAutoRefreshEnabled: (isTokenAutoRefreshEnabled: boolean) => + setTokenAutoRefreshEnabled(app, isTokenAutoRefreshEnabled) + }; +} + +export function internalFactory( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'> +): FirebaseAppCheckInternal { + return { + getToken: forceRefresh => + getToken(app, platformLoggerProvider, forceRefresh), + addTokenListener: listener => + addTokenListener(app, platformLoggerProvider, listener), + removeTokenListener: listener => removeTokenListener(app, listener) + }; +} diff --git a/packages/app-check/src/index.ts b/packages/app-check/src/index.ts new file mode 100644 index 00000000000..9ab87738571 --- /dev/null +++ b/packages/app-check/src/index.ts @@ -0,0 +1,100 @@ +/** + * @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 firebase from '@firebase/app'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; +import { + Component, + ComponentType, + InstantiationMode +} from '@firebase/component'; +import { + FirebaseAppCheck, + AppCheckComponentName +} from '@firebase/app-check-types'; +import { factory, internalFactory } from './factory'; +import { initializeDebugMode } from './debug'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; +import { name, version } from '../package.json'; + +const APP_CHECK_NAME: AppCheckComponentName = 'appCheck'; +const APP_CHECK_NAME_INTERNAL: AppCheckInternalComponentName = + 'app-check-internal'; +function registerAppCheck(firebase: _FirebaseNamespace): void { + // The public interface + firebase.INTERNAL.registerComponent( + new Component( + APP_CHECK_NAME, + container => { + // getImmediate for FirebaseApp will always succeed + const app = container.getProvider('app').getImmediate(); + return factory(app); + }, + ComponentType.PUBLIC + ) + /** + * AppCheck can only be initialized by explicitly calling firebase.appCheck() + * We don't want firebase products that consume AppCheck to gate on AppCheck + * if the user doesn't intend them to, just because the AppCheck component + * is registered. + */ + .setInstantiationMode(InstantiationMode.EXPLICIT) + /** + * Because all firebase products that depend on app-check depend on app-check-internal directly, + * we need to initialize app-check-internal after app-check is initialized to make it + * available to other firebase products. + */ + .setInstanceCreatedCallback( + (container, _instanceIdentifier, _instance) => { + const appCheckInternalProvider = container.getProvider( + APP_CHECK_NAME_INTERNAL + ); + appCheckInternalProvider.initialize(); + } + ) + ); + + // The internal interface used by other Firebase products + firebase.INTERNAL.registerComponent( + new Component( + APP_CHECK_NAME_INTERNAL, + container => { + // getImmediate for FirebaseApp will always succeed + const app = container.getProvider('app').getImmediate(); + const platformLoggerProvider = container.getProvider('platform-logger'); + return internalFactory(app, platformLoggerProvider); + }, + ComponentType.PUBLIC + ).setInstantiationMode(InstantiationMode.EXPLICIT) + ); + + firebase.registerVersion(name, version); +} + +registerAppCheck(firebase as _FirebaseNamespace); +initializeDebugMode(); + +/** + * Define extension behavior of `registerAnalytics` + */ +declare module '@firebase/app-types' { + interface FirebaseNamespace { + appCheck(app?: FirebaseApp): FirebaseAppCheck; + } + interface FirebaseApp { + appCheck(): FirebaseAppCheck; + } +} diff --git a/packages/app-check/src/indexeddb.ts b/packages/app-check/src/indexeddb.ts new file mode 100644 index 00000000000..4ba72637393 --- /dev/null +++ b/packages/app-check/src/indexeddb.ts @@ -0,0 +1,151 @@ +/** + * @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 { FirebaseApp } from '@firebase/app-types'; +import { ERROR_FACTORY, AppCheckError } from './errors'; +import { AppCheckTokenInternal } from './state'; +const DB_NAME = 'firebase-app-check-database'; +const DB_VERSION = 1; +const STORE_NAME = 'firebase-app-check-store'; +const DEBUG_TOKEN_KEY = 'debug-token'; + +let dbPromise: Promise | null = null; +function getDBPromise(): Promise { + if (dbPromise) { + return dbPromise; + } + + dbPromise = new Promise((resolve, reject) => { + try { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onsuccess = event => { + resolve((event.target as IDBOpenDBRequest).result); + }; + + request.onerror = event => { + reject( + ERROR_FACTORY.create(AppCheckError.STORAGE_OPEN, { + originalErrorMessage: (event.target as IDBRequest).error?.message + }) + ); + }; + + request.onupgradeneeded = event => { + const db = (event.target as IDBOpenDBRequest).result; + + // We don't use 'break' in this switch statement, the fall-through + // behavior is what we want, because if there are multiple versions between + // the old version and the current version, we want ALL the migrations + // that correspond to those versions to run, not only the last one. + // eslint-disable-next-line default-case + switch (event.oldVersion) { + case 0: + db.createObjectStore(STORE_NAME, { + keyPath: 'compositeKey' + }); + } + }; + } catch (e) { + reject( + ERROR_FACTORY.create(AppCheckError.STORAGE_OPEN, { + originalErrorMessage: e.message + }) + ); + } + }); + + return dbPromise; +} + +export function readTokenFromIndexedDB( + app: FirebaseApp +): Promise { + return read(computeKey(app)) as Promise; +} + +export function writeTokenToIndexedDB( + app: FirebaseApp, + token: AppCheckTokenInternal +): Promise { + return write(computeKey(app), token); +} + +export function writeDebugTokenToIndexedDB(token: string): Promise { + return write(DEBUG_TOKEN_KEY, token); +} + +export function readDebugTokenFromIndexedDB(): Promise { + return read(DEBUG_TOKEN_KEY) as Promise; +} + +async function write(key: string, value: unknown): Promise { + const db = await getDBPromise(); + + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.put({ + compositeKey: key, + value + }); + + return new Promise((resolve, reject) => { + request.onsuccess = _event => { + resolve(); + }; + + transaction.onerror = event => { + reject( + ERROR_FACTORY.create(AppCheckError.STORAGE_WRITE, { + originalErrorMessage: (event.target as IDBRequest).error?.message + }) + ); + }; + }); +} + +async function read(key: string): Promise { + const db = await getDBPromise(); + + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(key); + + return new Promise((resolve, reject) => { + request.onsuccess = event => { + const result = (event.target as IDBRequest).result; + + if (result) { + resolve(result.value); + } else { + resolve(undefined); + } + }; + + transaction.onerror = event => { + reject( + ERROR_FACTORY.create(AppCheckError.STORAGE_GET, { + originalErrorMessage: (event.target as IDBRequest).error?.message + }) + ); + }; + }); +} + +function computeKey(app: FirebaseApp): string { + return `${app.options.appId}-${app.name}`; +} diff --git a/packages/app-check/src/internal-api.test.ts b/packages/app-check/src/internal-api.test.ts new file mode 100644 index 00000000000..3d4abce1019 --- /dev/null +++ b/packages/app-check/src/internal-api.test.ts @@ -0,0 +1,408 @@ +/** + * @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 '../test/setup'; +import { expect } from 'chai'; +import { SinonStub, spy, stub, useFakeTimers } from 'sinon'; +import { FirebaseApp } from '@firebase/app-types'; +import { + FAKE_SITE_KEY, + getFakeApp, + getFakeCustomTokenProvider, + getFakePlatformLoggingProvider, + removegreCAPTCHAScriptsOnPage +} from '../test/util'; +import { activate } from './api'; +import { + getToken, + addTokenListener, + removeTokenListener, + formatDummyToken, + defaultTokenErrorData +} from './internal-api'; +import * as reCAPTCHA from './recaptcha'; +import * as client from './client'; +import * as storage from './storage'; +import { getState, clearState, setState, getDebugState } from './state'; +import { AppCheckTokenListener } from '@firebase/app-check-interop-types'; +import { Deferred } from '@firebase/util'; + +const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); + +describe('internal api', () => { + let app: FirebaseApp; + + beforeEach(() => { + app = getFakeApp(); + }); + + afterEach(() => { + clearState(); + removegreCAPTCHAScriptsOnPage(); + }); + // TODO: test error conditions + describe('getToken()', () => { + const fakeRecaptchaToken = 'fake-recaptcha-token'; + const fakeRecaptchaAppCheckToken = { + token: 'fake-recaptcha-app-check-token', + expireTimeMillis: 123, + issuedAtTimeMillis: 0 + }; + + const fakeCachedAppCheckToken = { + token: 'fake-cached-app-check-token', + expireTimeMillis: 123, + issuedAtTimeMillis: 0 + }; + + it('uses customTokenProvider to get an AppCheck token', async () => { + const clock = useFakeTimers(); + const customTokenProvider = getFakeCustomTokenProvider(); + const customProviderSpy = spy(customTokenProvider, 'getToken'); + + activate(app, customTokenProvider); + const token = await getToken(app, fakePlatformLoggingProvider); + + expect(customProviderSpy).to.be.called; + expect(token).to.deep.equal({ + token: 'fake-custom-app-check-token' + }); + + clock.restore(); + }); + + it('uses reCAPTCHA token to exchange for AppCheck token if no customTokenProvider is provided', async () => { + activate(app, FAKE_SITE_KEY); + + const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns( + Promise.resolve(fakeRecaptchaToken) + ); + const exchangeTokenStub: SinonStub = stub( + client, + 'exchangeToken' + ).returns(Promise.resolve(fakeRecaptchaAppCheckToken)); + + const token = await getToken(app, fakePlatformLoggingProvider); + + expect(reCAPTCHASpy).to.be.called; + + expect(exchangeTokenStub.args[0][0].body['recaptcha_token']).to.equal( + fakeRecaptchaToken + ); + expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); + }); + + it('resolves with a dummy token and an error if failed to get a token', async () => { + const errorStub = stub(console, 'error'); + activate(app, FAKE_SITE_KEY); + + const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns( + Promise.resolve(fakeRecaptchaToken) + ); + + const error = new Error('oops, something went wrong'); + stub(client, 'exchangeToken').returns(Promise.reject(error)); + + const token = await getToken(app, fakePlatformLoggingProvider); + + expect(reCAPTCHASpy).to.be.called; + expect(token).to.deep.equal({ + token: formatDummyToken(defaultTokenErrorData), + error + }); + expect(errorStub.args[0][1].message).to.include( + 'oops, something went wrong' + ); + errorStub.restore(); + }); + + it('notifies listeners using cached token', async () => { + activate(app, FAKE_SITE_KEY); + + const clock = useFakeTimers(); + stub(storage, 'readTokenFromStorage').returns( + Promise.resolve(fakeCachedAppCheckToken) + ); + + const listener1 = spy(); + const listener2 = spy(); + addTokenListener(app, fakePlatformLoggingProvider, listener1); + addTokenListener(app, fakePlatformLoggingProvider, listener2); + + await getToken(app, fakePlatformLoggingProvider); + + expect(listener1).to.be.calledWith({ + token: fakeCachedAppCheckToken.token + }); + expect(listener2).to.be.calledWith({ + token: fakeCachedAppCheckToken.token + }); + + clock.restore(); + }); + + it('notifies listeners using new token', async () => { + activate(app, FAKE_SITE_KEY); + + stub(storage, 'readTokenFromStorage').returns(Promise.resolve(undefined)); + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').returns( + Promise.resolve(fakeRecaptchaAppCheckToken) + ); + + const listener1 = spy(); + const listener2 = spy(); + addTokenListener(app, fakePlatformLoggingProvider, listener1); + addTokenListener(app, fakePlatformLoggingProvider, listener2); + + await getToken(app, fakePlatformLoggingProvider); + + expect(listener1).to.be.calledWith({ + token: fakeRecaptchaAppCheckToken.token + }); + expect(listener2).to.be.calledWith({ + token: fakeRecaptchaAppCheckToken.token + }); + }); + + it('ignores listeners that throw', async () => { + activate(app, FAKE_SITE_KEY); + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').returns( + Promise.resolve(fakeRecaptchaAppCheckToken) + ); + const listener1 = (): void => { + throw new Error(); + }; + const listener2 = spy(); + + addTokenListener(app, fakePlatformLoggingProvider, listener1); + addTokenListener(app, fakePlatformLoggingProvider, listener2); + + await getToken(app, fakePlatformLoggingProvider); + + expect(listener2).to.be.calledWith({ + token: fakeRecaptchaAppCheckToken.token + }); + }); + + it('loads persisted token to memory and returns it', async () => { + const clock = useFakeTimers(); + activate(app, FAKE_SITE_KEY); + + stub(storage, 'readTokenFromStorage').returns( + Promise.resolve(fakeCachedAppCheckToken) + ); + + const clientStub = stub(client, 'exchangeToken'); + + expect(getState(app).token).to.equal(undefined); + expect(await getToken(app, fakePlatformLoggingProvider)).to.deep.equal({ + token: fakeCachedAppCheckToken.token + }); + expect(getState(app).token).to.equal(fakeCachedAppCheckToken); + expect(clientStub).has.not.been.called; + + clock.restore(); + }); + + it('persists token to storage', async () => { + activate(app, FAKE_SITE_KEY); + + stub(storage, 'readTokenFromStorage').returns(Promise.resolve(undefined)); + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').returns( + Promise.resolve(fakeRecaptchaAppCheckToken) + ); + const storageWriteStub = stub(storage, 'writeTokenToStorage'); + const result = await getToken(app, fakePlatformLoggingProvider); + expect(result).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); + expect(storageWriteStub).has.been.calledWith( + app, + fakeRecaptchaAppCheckToken + ); + }); + + it('returns the valid token in memory without making network request', async () => { + const clock = useFakeTimers(); + activate(app, FAKE_SITE_KEY); + setState(app, { ...getState(app), token: fakeRecaptchaAppCheckToken }); + + const clientStub = stub(client, 'exchangeToken'); + expect(await getToken(app, fakePlatformLoggingProvider)).to.deep.equal({ + token: fakeRecaptchaAppCheckToken.token + }); + expect(clientStub).to.not.have.been.called; + + clock.restore(); + }); + + it('force to get new token when forceRefresh is true', async () => { + activate(app, FAKE_SITE_KEY); + setState(app, { ...getState(app), token: fakeRecaptchaAppCheckToken }); + + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').returns( + Promise.resolve(fakeRecaptchaAppCheckToken) + ); + + expect( + await getToken(app, fakePlatformLoggingProvider, true) + ).to.deep.equal({ + token: fakeRecaptchaAppCheckToken.token + }); + }); + + it('exchanges debug token if in debug mode', async () => { + const exchangeTokenStub: SinonStub = stub( + client, + 'exchangeToken' + ).returns(Promise.resolve(fakeRecaptchaAppCheckToken)); + const debugState = getDebugState(); + debugState.enabled = true; + debugState.token = new Deferred(); + debugState.token.resolve('my-debug-token'); + activate(app, FAKE_SITE_KEY); + + const token = await getToken(app, fakePlatformLoggingProvider); + expect(exchangeTokenStub.args[0][0].body['debug_token']).to.equal( + 'my-debug-token' + ); + expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); + }); + }); + + describe('addTokenListener', () => { + it('adds token listeners', () => { + const listener = (): void => {}; + + addTokenListener(app, fakePlatformLoggingProvider, listener); + + expect(getState(app).tokenListeners[0]).to.equal(listener); + }); + + it('starts proactively refreshing token after adding the first listener', () => { + const listener = (): void => {}; + setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); + expect(getState(app).tokenListeners.length).to.equal(0); + expect(getState(app).tokenRefresher).to.equal(undefined); + + addTokenListener(app, fakePlatformLoggingProvider, listener); + + expect(getState(app).tokenRefresher?.isRunning()).to.be.true; + }); + + it('notifies the listener with the valid token in memory immediately', done => { + const clock = useFakeTimers(); + const fakeListener: AppCheckTokenListener = token => { + expect(token).to.deep.equal({ + token: `fake-memory-app-check-token` + }); + clock.restore(); + done(); + }; + + setState(app, { + ...getState(app), + token: { + token: `fake-memory-app-check-token`, + expireTimeMillis: 123, + issuedAtTimeMillis: 0 + } + }); + + addTokenListener(app, fakePlatformLoggingProvider, fakeListener); + }); + + it('notifies the listener with the valid token in storage', done => { + const clock = useFakeTimers(); + activate(app, FAKE_SITE_KEY); + stub(storage, 'readTokenFromStorage').returns( + Promise.resolve({ + token: `fake-cached-app-check-token`, + expireTimeMillis: 123, + issuedAtTimeMillis: 0 + }) + ); + + const fakeListener: AppCheckTokenListener = token => { + expect(token).to.deep.equal({ + token: `fake-cached-app-check-token` + }); + clock.restore(); + done(); + }; + + addTokenListener(app, fakePlatformLoggingProvider, fakeListener); + clock.tick(1); + }); + + it('notifies the listener with the debug token immediately', done => { + const fakeListener: AppCheckTokenListener = token => { + expect(token).to.deep.equal({ + token: `my-debug-token` + }); + done(); + }; + + const debugState = getDebugState(); + debugState.enabled = true; + debugState.token = new Deferred(); + debugState.token.resolve('my-debug-token'); + + activate(app, FAKE_SITE_KEY); + addTokenListener(app, fakePlatformLoggingProvider, fakeListener); + }); + + it('does NOT start token refresher in debug mode', () => { + const debugState = getDebugState(); + debugState.enabled = true; + debugState.token = new Deferred(); + debugState.token.resolve('my-debug-token'); + + activate(app, FAKE_SITE_KEY); + addTokenListener(app, fakePlatformLoggingProvider, () => {}); + + const state = getState(app); + expect(state.tokenRefresher).is.undefined; + }); + }); + + describe('removeTokenListener', () => { + it('should remove token listeners', () => { + const listener = (): void => {}; + addTokenListener(app, fakePlatformLoggingProvider, listener); + expect(getState(app).tokenListeners.length).to.equal(1); + + removeTokenListener(app, listener); + expect(getState(app).tokenListeners.length).to.equal(0); + }); + + it('should stop proactively refreshing token after deleting the last listener', () => { + const listener = (): void => {}; + setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); + + addTokenListener(app, fakePlatformLoggingProvider, listener); + expect(getState(app).tokenListeners.length).to.equal(1); + expect(getState(app).tokenRefresher?.isRunning()).to.be.true; + + removeTokenListener(app, listener); + expect(getState(app).tokenListeners.length).to.equal(0); + expect(getState(app).tokenRefresher?.isRunning()).to.be.false; + }); + }); +}); diff --git a/packages/app-check/src/internal-api.ts b/packages/app-check/src/internal-api.ts new file mode 100644 index 00000000000..4e4b687edcf --- /dev/null +++ b/packages/app-check/src/internal-api.ts @@ -0,0 +1,325 @@ +/** + * @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 { getToken as getReCAPTCHAToken } from './recaptcha'; +import { FirebaseApp } from '@firebase/app-types'; +import { + AppCheckTokenResult, + AppCheckTokenListener +} from '@firebase/app-check-interop-types'; +import { + AppCheckTokenInternal, + getDebugState, + getState, + setState +} from './state'; +import { TOKEN_REFRESH_TIME } from './constants'; +import { Refresher } from './proactive-refresh'; +import { ensureActivated } from './util'; +import { + exchangeToken, + getExchangeDebugTokenRequest, + getExchangeRecaptchaTokenRequest +} from './client'; +import { writeTokenToStorage, readTokenFromStorage } from './storage'; +import { getDebugToken, isDebugMode } from './debug'; +import { base64, issuedAtTime } from '@firebase/util'; +import { ERROR_FACTORY, AppCheckError } from './errors'; +import { logger } from './logger'; +import { Provider } from '@firebase/component'; + +// Initial hardcoded value agreed upon across platforms for initial launch. +// Format left open for possible dynamic error values and other fields in the future. +export const defaultTokenErrorData = { error: 'UNKNOWN_ERROR' }; + +/** + * Stringify and base64 encode token error data. + * + * @param tokenError Error data, currently hardcoded. + */ +export function formatDummyToken( + tokenErrorData: Record +): string { + return base64.encodeString( + JSON.stringify(tokenErrorData), + /* webSafe= */ false + ); +} + +/** + * This function will always resolve. + * The result will contain an error field if there is any error. + * In case there is an error, the token field in the result will be populated with a dummy value + */ +export async function getToken( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'>, + forceRefresh = false +): Promise { + ensureActivated(app); + /** + * DEBUG MODE + * return the debug token directly + */ + if (isDebugMode()) { + const tokenFromDebugExchange: AppCheckTokenInternal = await exchangeToken( + getExchangeDebugTokenRequest(app, await getDebugToken()), + platformLoggerProvider + ); + return { token: tokenFromDebugExchange.token }; + } + + const state = getState(app); + + let token: AppCheckTokenInternal | undefined = state.token; + let error: Error | undefined = undefined; + + /** + * try to load token from indexedDB if it's the first time this function is called + */ + if (!token) { + // readTokenFromStorage() always resolves. In case of an error, it resolves with `undefined`. + const cachedToken = await readTokenFromStorage(app); + if (cachedToken && isValid(cachedToken)) { + token = cachedToken; + + setState(app, { ...state, token }); + // notify all listeners with the cached token + notifyTokenListeners(app, { token: token.token }); + } + } + + // return the cached token if it's valid + if (!forceRefresh && token && isValid(token)) { + return { + token: token.token + }; + } + + /** + * request a new token + */ + try { + if (state.customProvider) { + const customToken = await state.customProvider.getToken(); + // Try to extract IAT from custom token, in case this token is not + // being newly issued. JWT timestamps are in seconds since epoch. + const issuedAtTimeSeconds = issuedAtTime(customToken.token); + // Very basic validation, use current timestamp as IAT if JWT + // has no `iat` field or value is out of bounds. + const issuedAtTimeMillis = + issuedAtTimeSeconds !== null && + issuedAtTimeSeconds < Date.now() && + issuedAtTimeSeconds > 0 + ? issuedAtTimeSeconds * 1000 + : Date.now(); + + token = { ...customToken, issuedAtTimeMillis }; + } else { + const attestedClaimsToken = await getReCAPTCHAToken(app).catch(_e => { + // reCaptcha.execute() throws null which is not very descriptive. + throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR); + }); + token = await exchangeToken( + getExchangeRecaptchaTokenRequest(app, attestedClaimsToken), + platformLoggerProvider + ); + } + } catch (e) { + // `getToken()` should never throw, but logging error text to console will aid debugging. + logger.error(e); + error = e; + } + + let interopTokenResult: AppCheckTokenResult | undefined; + if (!token) { + // if token is undefined, there must be an error. + // we return a dummy token along with the error + interopTokenResult = makeDummyTokenResult(error!); + } else { + interopTokenResult = { + token: token.token + }; + // write the new token to the memory state as well ashe persistent storage. + // Only do it if we got a valid new token + setState(app, { ...state, token }); + await writeTokenToStorage(app, token); + } + + notifyTokenListeners(app, interopTokenResult); + return interopTokenResult; +} + +export function addTokenListener( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'>, + listener: AppCheckTokenListener +): void { + const state = getState(app); + const newState = { + ...state, + tokenListeners: [...state.tokenListeners, listener] + }; + + /** + * DEBUG MODE + * + * invoke the listener once with the debug token. + */ + if (isDebugMode()) { + const debugState = getDebugState(); + if (debugState.enabled && debugState.token) { + debugState.token.promise + .then(token => listener({ token })) + .catch(() => { + /* we don't care about exceptions thrown in listeners */ + }); + } + } else { + /** + * PROD MODE + * + * invoke the listener with the valid token, then start the token refresher + */ + if (!newState.tokenRefresher) { + const tokenRefresher = createTokenRefresher(app, platformLoggerProvider); + newState.tokenRefresher = tokenRefresher; + } + + // Create the refresher but don't start it if `isTokenAutoRefreshEnabled` + // is not true. + if ( + !newState.tokenRefresher.isRunning() && + state.isTokenAutoRefreshEnabled === true + ) { + newState.tokenRefresher.start(); + } + + // invoke the listener async immediately if there is a valid token + if (state.token && isValid(state.token)) { + const validToken = state.token; + Promise.resolve() + .then(() => listener({ token: validToken.token })) + .catch(() => { + /* we don't care about exceptions thrown in listeners */ + }); + } + } + + setState(app, newState); +} + +export function removeTokenListener( + app: FirebaseApp, + listener: AppCheckTokenListener +): void { + const state = getState(app); + + const newListeners = state.tokenListeners.filter(l => l !== listener); + if ( + newListeners.length === 0 && + state.tokenRefresher && + state.tokenRefresher.isRunning() + ) { + state.tokenRefresher.stop(); + } + + setState(app, { + ...state, + tokenListeners: newListeners + }); +} + +function createTokenRefresher( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'> +): Refresher { + return new Refresher( + // Keep in mind when this fails for any reason other than the ones + // for which we should retry, it will effectively stop the proactive refresh. + async () => { + const state = getState(app); + // If there is no token, we will try to load it from storage and use it + // If there is a token, we force refresh it because we know it's going to expire soon + let result; + if (!state.token) { + result = await getToken(app, platformLoggerProvider); + } else { + result = await getToken(app, platformLoggerProvider, true); + } + + // getToken() always resolves. In case the result has an error field defined, it means the operation failed, and we should retry. + if (result.error) { + throw result.error; + } + }, + () => { + // TODO: when should we retry? + return true; + }, + () => { + const state = getState(app); + + if (state.token) { + // issuedAtTime + (50% * total TTL) + 5 minutes + let nextRefreshTimeMillis = + state.token.issuedAtTimeMillis + + (state.token.expireTimeMillis - state.token.issuedAtTimeMillis) * + 0.5 + + 5 * 60 * 1000; + // Do not allow refresh time to be past (expireTime - 5 minutes) + const latestAllowableRefresh = + state.token.expireTimeMillis - 5 * 60 * 1000; + nextRefreshTimeMillis = Math.min( + nextRefreshTimeMillis, + latestAllowableRefresh + ); + return Math.max(0, nextRefreshTimeMillis - Date.now()); + } else { + return 0; + } + }, + TOKEN_REFRESH_TIME.RETRIAL_MIN_WAIT, + TOKEN_REFRESH_TIME.RETRIAL_MAX_WAIT + ); +} + +function notifyTokenListeners( + app: FirebaseApp, + token: AppCheckTokenResult +): void { + const listeners = getState(app).tokenListeners; + + for (const listener of listeners) { + try { + listener(token); + } catch (e) { + // If any handler fails, ignore and run next handler. + } + } +} + +function isValid(token: AppCheckTokenInternal): boolean { + return token.expireTimeMillis - Date.now() > 0; +} + +function makeDummyTokenResult(error: Error): AppCheckTokenResult { + return { + token: formatDummyToken(defaultTokenErrorData), + error + }; +} diff --git a/packages/app-check/src/logger.ts b/packages/app-check/src/logger.ts new file mode 100644 index 00000000000..45896c6a779 --- /dev/null +++ b/packages/app-check/src/logger.ts @@ -0,0 +1,20 @@ +/** + * @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 } from '@firebase/logger'; + +export const logger = new Logger('@firebase/app-check'); diff --git a/packages/app-check/src/proactive-refresh.test.ts b/packages/app-check/src/proactive-refresh.test.ts new file mode 100644 index 00000000000..e0944ef43ff --- /dev/null +++ b/packages/app-check/src/proactive-refresh.test.ts @@ -0,0 +1,201 @@ +/** + * @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 '../test/setup'; +import { useFakeTimers } from 'sinon'; +import { expect } from 'chai'; +import { Deferred } from '@firebase/util'; +import { Refresher } from './proactive-refresh'; + +describe('proactive refresh', () => { + it('throws if lowerbound is greater than the upperbound', () => { + expect( + () => + new Refresher( + () => Promise.resolve(), + () => false, + () => 0, + 100, + 99 + ) + ).to.throw(/Proactive refresh lower bound greater than upper bound!/); + }); + + it('runs operation after wait', async () => { + const clock = useFakeTimers(); + const operations = [new Deferred(), new Deferred(), new Deferred()]; + let counter = 0; + const waitTime = 10; + const refresher = new Refresher( + () => { + const operation = operations[counter++]; + operation.resolve(); + return operation.promise; + }, + () => false, + () => waitTime, + 1, + 100 + ); + + expect(refresher.isRunning()).to.be.false; + refresher.start(); + expect(refresher.isRunning()).to.be.true; + + clock.tick(waitTime); + await expect(operations[0].promise).to.eventually.fulfilled; + clock.tick(waitTime); + await expect(operations[1].promise).to.eventually.fulfilled; + clock.tick(waitTime); + await expect(operations[2].promise).to.eventually.fulfilled; + + clock.restore(); + }); + + it('retries on retriable errors', async () => { + const waitTime = 10; + let counter = 0; + const successOperation = new Deferred(); + const refresher = new Refresher( + () => { + if (counter++ === 0) { + return Promise.reject('Error but retriable'); + } else { + successOperation.resolve(); + return successOperation.promise; + } + }, + error => (error as string).includes('Error but retriable'), + () => waitTime, + 1, + 100 + ); + + refresher.start(); + + await expect(successOperation.promise).to.eventually.fulfilled; + expect(refresher.isRunning()).to.be.true; + }); + + it('does not retry and stop refreshing on non-retriable errors', async () => { + const waitTime = 10; + const retryCheck = new Deferred(); + const refresher = new Refresher( + () => Promise.reject('non-retriable'), + error => { + retryCheck.resolve(); + return (error as string).includes('Error but retriable'); + }, + () => waitTime, + 1, + 100 + ); + + refresher.start(); + + await retryCheck.promise; + expect(refresher.isRunning()).to.be.false; + }); + + it('backs off exponentially when retrying', async () => { + const clock = useFakeTimers(); + const minWaitTime = 10; + const maxWaitTime = 100; + let counter = 0; + const operations = [new Deferred(), new Deferred()]; + const refresher = new Refresher( + () => { + operations[counter++].resolve(); + return Promise.reject('Error but retriable'); + }, + error => (error as string).includes('Error but retriable'), + () => minWaitTime, + minWaitTime, + maxWaitTime + ); + + refresher.start(); + + clock.tick(minWaitTime); + + await expect(operations[0].promise).to.eventually.fulfilled; + clock.tick(minWaitTime * 2); + await expect(operations[1].promise).to.eventually.fulfilled; + + refresher.stop(); + clock.restore(); + }); + + it('can be stopped during wait', async () => { + const clock = useFakeTimers(); + const waitTime = 10; + const operation = new Deferred(); + const refresher = new Refresher( + () => { + operation.resolve(); + return operation.promise; + }, + _error => false, + () => waitTime, + 10, + 100 + ); + + refresher.start(); + clock.tick(0.5 * waitTime); + refresher.stop(); + clock.tick(waitTime); + + operation.reject('not resolved'); + await expect(operation.promise).to.eventually.rejectedWith('not resolved'); + expect(refresher.isRunning()).to.be.false; + clock.restore(); + }); + + it('can be restarted after being stopped', async () => { + const clock = useFakeTimers(); + const waitTime = 10; + const operation = new Deferred(); + const operationAfterRestart = new Deferred(); + const refresher = new Refresher( + () => { + operation.resolve(); + operationAfterRestart.resolve(); + return operation.promise; + }, + _error => false, + () => waitTime, + 10, + 100 + ); + + refresher.start(); + clock.tick(0.5 * waitTime); + refresher.stop(); + clock.tick(waitTime); + + operation.reject('not resolved'); + await expect(operation.promise).to.eventually.rejectedWith('not resolved'); + expect(refresher.isRunning()).to.be.false; + + refresher.start(); + clock.tick(waitTime); + await expect(operationAfterRestart.promise).to.eventually.fulfilled; + + clock.restore(); + }); +}); diff --git a/packages/app-check/src/proactive-refresh.ts b/packages/app-check/src/proactive-refresh.ts new file mode 100644 index 00000000000..89bc046935b --- /dev/null +++ b/packages/app-check/src/proactive-refresh.ts @@ -0,0 +1,121 @@ +/** + * @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 { Deferred } from '@firebase/util'; + +/** + * Port from auth proactiverefresh.js + * + */ +// TODO: move it to @firebase/util? +// TODO: allow to config whether refresh should happen in the background +export class Refresher { + private pending: Deferred | null = null; + private nextErrorWaitInterval: number; + constructor( + private readonly operation: () => Promise, + private readonly retryPolicy: (error: unknown) => boolean, + private readonly getWaitDuration: () => number, + private readonly lowerBound: number, + private readonly upperBound: number + ) { + this.nextErrorWaitInterval = lowerBound; + + if (lowerBound > upperBound) { + throw new Error( + 'Proactive refresh lower bound greater than upper bound!' + ); + } + } + + start(): void { + this.nextErrorWaitInterval = this.lowerBound; + this.process(true).catch(() => { + /* we don't care about the result */ + }); + } + + stop(): void { + if (this.pending) { + this.pending.reject('cancelled'); + this.pending = null; + } + } + + isRunning(): boolean { + return !!this.pending; + } + + private async process(hasSucceeded: boolean): Promise { + this.stop(); + try { + this.pending = new Deferred(); + await sleep(this.getNextRun(hasSucceeded)); + + // Why do we resolve a promise, then immediate wait for it? + // We do it to make the promise chain cancellable. + // We can call stop() which rejects the promise before the following line execute, which makes + // the code jump to the catch block. + // TODO: unit test this + this.pending.resolve(); + await this.pending.promise; + this.pending = new Deferred(); + await this.operation(); + + this.pending.resolve(); + await this.pending.promise; + + this.process(true).catch(() => { + /* we don't care about the result */ + }); + } catch (error) { + if (this.retryPolicy(error)) { + this.process(false).catch(() => { + /* we don't care about the result */ + }); + } else { + this.stop(); + } + } + } + + private getNextRun(hasSucceeded: boolean): number { + if (hasSucceeded) { + // If last operation succeeded, reset next error wait interval and return + // the default wait duration. + this.nextErrorWaitInterval = this.lowerBound; + // Return typical wait duration interval after a successful operation. + return this.getWaitDuration(); + } else { + // Get next error wait interval. + const currentErrorWaitInterval = this.nextErrorWaitInterval; + // Double interval for next consecutive error. + this.nextErrorWaitInterval *= 2; + // Make sure next wait interval does not exceed the maximum upper bound. + if (this.nextErrorWaitInterval > this.upperBound) { + this.nextErrorWaitInterval = this.upperBound; + } + return currentErrorWaitInterval; + } + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/packages/app-check/src/recaptcha.test.ts b/packages/app-check/src/recaptcha.test.ts new file mode 100644 index 00000000000..581d564862b --- /dev/null +++ b/packages/app-check/src/recaptcha.test.ts @@ -0,0 +1,122 @@ +/** + * @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 '../test/setup'; +import { expect } from 'chai'; +import { stub } from 'sinon'; +import { FirebaseApp } from '@firebase/app-types'; +import { + getFakeApp, + getFakeGreCAPTCHA, + removegreCAPTCHAScriptsOnPage, + findgreCAPTCHAScriptsOnPage, + FAKE_SITE_KEY +} from '../test/util'; +import { initialize, getToken } from './recaptcha'; +import * as utils from './util'; +import { getState } from './state'; +import { Deferred } from '@firebase/util'; +import { activate } from './api'; + +describe('recaptcha', () => { + let app: FirebaseApp; + + beforeEach(() => { + app = getFakeApp(); + }); + + afterEach(() => { + removegreCAPTCHAScriptsOnPage(); + }); + + describe('initialize()', () => { + it('sets reCAPTCHAState', async () => { + self.grecaptcha = getFakeGreCAPTCHA(); + expect(getState(app).reCAPTCHAState).to.equal(undefined); + await initialize(app, FAKE_SITE_KEY); + expect(getState(app).reCAPTCHAState?.initialized).to.be.instanceof( + Deferred + ); + }); + + it('loads reCAPTCHA script if it was not loaded already', async () => { + const fakeRecaptcha = getFakeGreCAPTCHA(); + let count = 0; + stub(utils, 'getRecaptcha').callsFake(() => { + count++; + if (count === 1) { + return undefined; + } + + return fakeRecaptcha; + }); + + expect(findgreCAPTCHAScriptsOnPage().length).to.equal(0); + await initialize(app, FAKE_SITE_KEY); + expect(findgreCAPTCHAScriptsOnPage().length).to.equal(1); + }); + + it('creates invisible widget', async () => { + const grecaptchaFake = getFakeGreCAPTCHA(); + const renderStub = stub(grecaptchaFake, 'render').callThrough(); + self.grecaptcha = grecaptchaFake; + + await initialize(app, FAKE_SITE_KEY); + + expect(renderStub).to.be.calledWith(`fire_app_check_${app.name}`, { + sitekey: FAKE_SITE_KEY, + size: 'invisible' + }); + + expect(getState(app).reCAPTCHAState?.widgetId).to.equal('fake_widget_1'); + }); + }); + + describe('getToken()', () => { + it('throws if AppCheck has not been activated yet', () => { + return expect(getToken(app)).to.eventually.rejectedWith( + /AppCheck is being used before activate\(\) is called/ + ); + }); + + it('calls recaptcha.execute with correct widgetId', async () => { + const grecaptchaFake = getFakeGreCAPTCHA(); + const executeStub = stub(grecaptchaFake, 'execute').returns( + Promise.resolve('fake-recaptcha-token') + ); + self.grecaptcha = grecaptchaFake; + activate(app, FAKE_SITE_KEY); + await getToken(app); + + expect(executeStub).to.have.been.calledWith('fake_widget_1', { + action: 'fire_app_check' + }); + }); + + it('resolves with token returned by recaptcha.execute', async () => { + const grecaptchaFake = getFakeGreCAPTCHA(); + stub(grecaptchaFake, 'execute').returns( + Promise.resolve('fake-recaptcha-token') + ); + self.grecaptcha = grecaptchaFake; + activate(app, FAKE_SITE_KEY); + const token = await getToken(app); + + expect(token).to.equal('fake-recaptcha-token'); + }); + }); +}); diff --git a/packages/app-check/src/recaptcha.ts b/packages/app-check/src/recaptcha.ts new file mode 100644 index 00000000000..573f4b83db1 --- /dev/null +++ b/packages/app-check/src/recaptcha.ts @@ -0,0 +1,139 @@ +/** + * @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 { FirebaseApp } from '@firebase/app-types'; +import { getState, setState } from './state'; +import { Deferred } from '@firebase/util'; +import { getRecaptcha, ensureActivated } from './util'; + +export const RECAPTCHA_URL = 'https://www.google.com/recaptcha/api.js'; + +export function initialize( + app: FirebaseApp, + siteKey: string +): Promise { + const state = getState(app); + const initialized = new Deferred(); + + setState(app, { ...state, reCAPTCHAState: { initialized } }); + + const divId = `fire_app_check_${app.name}`; + const invisibleDiv = document.createElement('div'); + invisibleDiv.id = divId; + invisibleDiv.style.display = 'none'; + + document.body.appendChild(invisibleDiv); + + const grecaptcha = getRecaptcha(); + if (!grecaptcha) { + loadReCAPTCHAScript(() => { + const grecaptcha = getRecaptcha(); + + if (!grecaptcha) { + // it shouldn't happen. + throw new Error('no recaptcha'); + } + grecaptcha.ready(() => { + // Invisible widgets allow us to set a different siteKey for each widget, so we use them to support multiple apps + renderInvisibleWidget(app, siteKey, grecaptcha, divId); + initialized.resolve(grecaptcha); + }); + }); + } else { + grecaptcha.ready(() => { + renderInvisibleWidget(app, siteKey, grecaptcha, divId); + initialized.resolve(grecaptcha); + }); + } + + return initialized.promise; +} + +export async function getToken(app: FirebaseApp): Promise { + ensureActivated(app); + + // ensureActivated() guarantees that reCAPTCHAState is set + const reCAPTCHAState = getState(app).reCAPTCHAState!; + const recaptcha = await reCAPTCHAState.initialized.promise; + + return new Promise((resolve, _reject) => { + // Updated after initialization is complete. + const reCAPTCHAState = getState(app).reCAPTCHAState!; + recaptcha.ready(() => { + resolve( + // widgetId is guaranteed to be available if reCAPTCHAState.initialized.promise resolved. + recaptcha.execute(reCAPTCHAState.widgetId!, { + action: 'fire_app_check' + }) + ); + }); + }); +} + +/** + * + * @param app + * @param container - Id of a HTML element. + */ +function renderInvisibleWidget( + app: FirebaseApp, + siteKey: string, + grecaptcha: GreCAPTCHA, + container: string +): void { + const widgetId = grecaptcha.render(container, { + sitekey: siteKey, + size: 'invisible' + }); + + const state = getState(app); + + setState(app, { + ...state, + reCAPTCHAState: { + ...state.reCAPTCHAState!, // state.reCAPTCHAState is set in the initialize() + widgetId + } + }); +} + +function loadReCAPTCHAScript(onload: () => void): void { + const script = document.createElement('script'); + script.src = `${RECAPTCHA_URL}`; + script.onload = onload; + document.head.appendChild(script); +} + +declare global { + interface Window { + grecaptcha: GreCAPTCHA | undefined; + } +} + +export interface GreCAPTCHA { + ready: (callback: () => void) => void; + execute: (siteKey: string, options: { action: string }) => Promise; + render: ( + container: string | HTMLElement, + parameters: GreCAPTCHARenderOption + ) => string; +} + +export interface GreCAPTCHARenderOption { + sitekey: string; + size: 'invisible'; +} diff --git a/packages/app-check/src/state.ts b/packages/app-check/src/state.ts new file mode 100644 index 00000000000..5d041ec82a4 --- /dev/null +++ b/packages/app-check/src/state.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 { FirebaseApp } from '@firebase/app-types'; +import { AppCheckProvider, AppCheckToken } from '@firebase/app-check-types'; +import { AppCheckTokenListener } from '@firebase/app-check-interop-types'; +import { Refresher } from './proactive-refresh'; +import { Deferred } from '@firebase/util'; +import { GreCAPTCHA } from './recaptcha'; + +export interface AppCheckTokenInternal extends AppCheckToken { + issuedAtTimeMillis: number; +} +export interface AppCheckState { + activated: boolean; + tokenListeners: AppCheckTokenListener[]; + customProvider?: AppCheckProvider; + siteKey?: string; + token?: AppCheckTokenInternal; + tokenRefresher?: Refresher; + reCAPTCHAState?: ReCAPTCHAState; + isTokenAutoRefreshEnabled?: boolean; +} + +export interface ReCAPTCHAState { + initialized: Deferred; + widgetId?: string; +} + +export interface DebugState { + enabled: boolean; + token?: Deferred; +} + +const APP_CHECK_STATES = new Map(); +export const DEFAULT_STATE: AppCheckState = { + activated: false, + tokenListeners: [] +}; + +const DEBUG_STATE: DebugState = { + enabled: false +}; + +export function getState(app: FirebaseApp): AppCheckState { + return APP_CHECK_STATES.get(app) || DEFAULT_STATE; +} + +export function setState(app: FirebaseApp, state: AppCheckState): void { + APP_CHECK_STATES.set(app, state); +} + +// for testing only +export function clearState(): void { + APP_CHECK_STATES.clear(); + DEBUG_STATE.enabled = false; + DEBUG_STATE.token = undefined; +} + +export function getDebugState(): DebugState { + return DEBUG_STATE; +} diff --git a/packages/app-check/src/storage.test.ts b/packages/app-check/src/storage.test.ts new file mode 100644 index 00000000000..763dbf3e661 --- /dev/null +++ b/packages/app-check/src/storage.test.ts @@ -0,0 +1,70 @@ +/** + * @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 '../test/setup'; +import { writeTokenToStorage, readTokenFromStorage } from './storage'; +import * as indexeddbOperations from './indexeddb'; +import { getFakeApp } from '../test/util'; +import * as util from '@firebase/util'; +import { logger } from './logger'; +import { expect } from 'chai'; +import { stub } from 'sinon'; + +describe('Storage', () => { + const app = getFakeApp(); + const fakeToken = { + token: 'fake-app-check-token', + expireTimeMillis: 345, + issuedAtTimeMillis: 0 + }; + + it('sets and gets appCheck token to indexeddb', async () => { + await writeTokenToStorage(app, fakeToken); + expect(await readTokenFromStorage(app)).to.deep.equal(fakeToken); + }); + + it('no op for writeTokenToStorage() if indexeddb is not available', async () => { + stub(util, 'isIndexedDBAvailable').returns(false); + await writeTokenToStorage(app, fakeToken); + expect(await readTokenFromStorage(app)).to.equal(undefined); + }); + + it('writeTokenToStorage() still resolves if writing to indexeddb failed', async () => { + const warnStub = stub(logger, 'warn'); + stub(indexeddbOperations, 'writeTokenToIndexedDB').returns( + Promise.reject('something went wrong!') + ); + await expect(writeTokenToStorage(app, fakeToken)).to.eventually.fulfilled; + expect(warnStub.args[0][0]).to.include('something went wrong!'); + warnStub.restore(); + }); + + it('resolves with undefined if indexeddb is not available', async () => { + stub(util, 'isIndexedDBAvailable').returns(false); + expect(await readTokenFromStorage(app)).to.equal(undefined); + }); + + it('resolves with undefined if reading indexeddb failed', async () => { + const warnStub = stub(logger, 'warn'); + stub(indexeddbOperations, 'readTokenFromIndexedDB').returns( + Promise.reject('something went wrong!') + ); + expect(await readTokenFromStorage(app)).to.equal(undefined); + expect(warnStub.args[0][0]).to.include('something went wrong!'); + warnStub.restore(); + }); +}); diff --git a/packages/app-check/src/storage.ts b/packages/app-check/src/storage.ts new file mode 100644 index 00000000000..6baa8ce6735 --- /dev/null +++ b/packages/app-check/src/storage.ts @@ -0,0 +1,98 @@ +/** + * @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 { uuidv4 } from './util'; +import { FirebaseApp } from '@firebase/app-types'; +import { isIndexedDBAvailable } from '@firebase/util'; +import { + readDebugTokenFromIndexedDB, + readTokenFromIndexedDB, + writeDebugTokenToIndexedDB, + writeTokenToIndexedDB +} from './indexeddb'; +import { logger } from './logger'; +import { AppCheckTokenInternal } from './state'; + +/** + * Always resolves. In case of an error reading from indexeddb, resolve with undefined + */ +export async function readTokenFromStorage( + app: FirebaseApp +): Promise { + if (isIndexedDBAvailable()) { + let token = undefined; + try { + token = await readTokenFromIndexedDB(app); + } catch (e) { + // swallow the error and return undefined + logger.warn(`Failed to read token from indexeddb. Error: ${e}`); + } + return token; + } + + return undefined; +} + +/** + * Always resolves. In case of an error writing to indexeddb, print a warning and resolve the promise + */ +export function writeTokenToStorage( + app: FirebaseApp, + token: AppCheckTokenInternal +): Promise { + if (isIndexedDBAvailable()) { + return writeTokenToIndexedDB(app, token).catch(e => { + // swallow the error and resolve the promise + logger.warn(`Failed to write token to indexeddb. Error: ${e}`); + }); + } + + return Promise.resolve(); +} + +export async function readOrCreateDebugTokenFromStorage(): Promise { + /** + * Theoretically race condition can happen if we read, then write in 2 separate transactions. + * But it won't happen here, because this function will be called exactly once. + */ + let existingDebugToken: string | undefined = undefined; + try { + existingDebugToken = await readDebugTokenFromIndexedDB(); + } catch (_e) { + // failed to read from indexeddb. We assume there is no existing debug token, and generate a new one. + } + + if (!existingDebugToken) { + // create a new debug token + const newToken = uuidv4(); + // We don't need to block on writing to indexeddb + // In case persistence failed, a new debug token will be generated everytime the page is refreshed. + // It renders the debug token useless because you have to manually register(whitelist) the new token in the firebase console again and again. + // If you see this error trying to use debug token, it probably means you are using a browser that doesn't support indexeddb. + // You should switch to a different browser that supports indexeddb + writeDebugTokenToIndexedDB(newToken).catch(e => + logger.warn(`Failed to persist debug token to indexeddb. Error: ${e}`) + ); + // Not using logger because I don't think we ever want this accidentally hidden? + console.log( + `AppCheck debug token: ${newToken}. You will need to whitelist it in the Firebase console for it to work` + ); + return newToken; + } else { + return existingDebugToken; + } +} diff --git a/packages/app-check/src/util.ts b/packages/app-check/src/util.ts new file mode 100644 index 00000000000..341cb550ee3 --- /dev/null +++ b/packages/app-check/src/util.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 { GreCAPTCHA } from './recaptcha'; +import { getState } from './state'; +import { ERROR_FACTORY, AppCheckError } from './errors'; +import { FirebaseApp } from '@firebase/app-types'; + +export function getRecaptcha(): GreCAPTCHA | undefined { + return self.grecaptcha; +} + +export function ensureActivated(app: FirebaseApp): void { + if (!getState(app).activated) { + throw ERROR_FACTORY.create(AppCheckError.USE_BEFORE_ACTIVATION, { + appName: app.name + }); + } +} + +/** + * Copied from https://stackoverflow.com/a/2117523 + */ +export function uuidv4(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0, + v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} diff --git a/packages/app-check/test/setup.ts b/packages/app-check/test/setup.ts new file mode 100644 index 00000000000..4426a0a8bf4 --- /dev/null +++ b/packages/app-check/test/setup.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. + */ + +import { use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { restore } from 'sinon'; +import * as sinonChai from 'sinon-chai'; + +use(chaiAsPromised); +use(sinonChai); + +afterEach(async () => { + restore(); +}); diff --git a/packages/app-check/test/util.ts b/packages/app-check/test/util.ts new file mode 100644 index 00000000000..b348128a971 --- /dev/null +++ b/packages/app-check/test/util.ts @@ -0,0 +1,110 @@ +/** + * @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 { FirebaseApp } from '@firebase/app-types'; +import { AppCheckProvider } from '@firebase/app-check-types'; +import { GreCAPTCHA, RECAPTCHA_URL } from '../src/recaptcha'; +import { + Provider, + ComponentContainer, + Component, + ComponentType +} from '@firebase/component'; + +export const FAKE_SITE_KEY = 'fake-site-key'; + +export function getFakeApp(overrides: Record = {}): FirebaseApp { + return { + name: 'appName', + options: { + apiKey: 'apiKey', + projectId: 'projectId', + authDomain: 'authDomain', + messagingSenderId: 'messagingSenderId', + databaseURL: 'databaseUrl', + storageBucket: 'storageBucket', + appId: '1:777777777777:web:d93b5ca1475efe57' + } as any, + automaticDataCollectionEnabled: true, + delete: async () => {}, + // This won't be used in tests. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + appCheck: null as any, + ...overrides + }; +} + +export function getFakeCustomTokenProvider(): AppCheckProvider { + return { + getToken: () => + Promise.resolve({ + token: 'fake-custom-app-check-token', + expireTimeMillis: 1 + }) + }; +} + +export function getFakePlatformLoggingProvider( + fakeLogString: string = 'a/1.2.3 b/2.3.4' +): Provider<'platform-logger'> { + const container = new ComponentContainer('test'); + container.addComponent( + new Component( + 'platform-logger', + () => ({ getPlatformInfoString: () => fakeLogString }), + ComponentType.PRIVATE + ) + ); + + return container.getProvider('platform-logger'); +} + +export function getFakeGreCAPTCHA(): GreCAPTCHA { + return { + ready: callback => callback(), + render: (_container, _parameters) => 'fake_widget_1', + execute: (_siteKey, _options) => Promise.resolve('fake_recaptcha_token') + }; +} + +/** + * Returns all script tags in DOM matching our reCAPTCHA url pattern. + * Tests in other files may have inserted multiple reCAPTCHA scripts, because they don't + * care about it. + */ +export function findgreCAPTCHAScriptsOnPage(): HTMLScriptElement[] { + const scriptTags = window.document.getElementsByTagName('script'); + const tags = []; + for (const tag of Object.values(scriptTags)) { + if (tag.src && tag.src.includes(RECAPTCHA_URL)) { + tags.push(tag); + } + } + return tags; +} + +export function removegreCAPTCHAScriptsOnPage(): void { + const tags = findgreCAPTCHAScriptsOnPage(); + + for (const tag of tags) { + tag.remove(); + } + + if (self.grecaptcha) { + self.grecaptcha = undefined; + } +} diff --git a/packages/app-check/tsconfig.json b/packages/app-check/tsconfig.json new file mode 100644 index 00000000000..a06ed9a374c --- /dev/null +++ b/packages/app-check/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/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 5c3df3b83aa..4333d1e58a9 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,21 @@ # @firebase/app +## 0.6.22 + +### Patch Changes + +- [`60e834739`](https://github.com/firebase/firebase-js-sdk/commit/60e83473940e60f8390b1b0f97cf45a1733f66f0) [#4897](https://github.com/firebase/firebase-js-sdk/pull/4897) - Make App Check initialization explicit, to prevent unexpected errors for users who do not intend to use App Check. + +## 0.6.21 + +### Patch Changes + +- [`e123f241c`](https://github.com/firebase/firebase-js-sdk/commit/e123f241c0cf39a983645582c4e42b7a5bff7bd6) [#4857](https://github.com/firebase/firebase-js-sdk/pull/4857) - Add AppCheck platform logging string. + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/util@1.1.0 + ## 0.6.20 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 595eb8ed19c..5903a8a93a3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/app", - "version": "0.6.20", + "version": "0.6.22", "description": "The primary entrypoint to the Firebase JS SDK", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.node.cjs.js", @@ -29,9 +29,9 @@ "license": "Apache-2.0", "dependencies": { "@firebase/app-types": "0.6.2", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "@firebase/logger": "0.2.6", - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "tslib": "^2.1.0", "dom-storage": "2.1.0", "xmlhttprequest": "1.8.0" diff --git a/packages/app/src/constants.ts b/packages/app/src/constants.ts index 285cb840db2..054b81d8791 100644 --- a/packages/app/src/constants.ts +++ b/packages/app/src/constants.ts @@ -18,6 +18,7 @@ export const DEFAULT_ENTRY_NAME = '[DEFAULT]'; import { name as appName } from '../package.json'; import { name as analyticsName } from '../../analytics/package.json'; +import { name as appCheckName } from '../../app-check/package.json'; import { name as authName } from '../../auth/package.json'; import { name as databaseName } from '../../database/package.json'; import { name as functionsName } from '../../functions/package.json'; @@ -32,6 +33,7 @@ import { name as packageName } from '../../../package.json'; export const PLATFORM_LOG_STRING = { [appName]: 'fire-core', [analyticsName]: 'fire-analytics', + [appCheckName]: 'fire-app-check', [authName]: 'fire-auth', [databaseName]: 'fire-rtdb', [functionsName]: 'fire-fn', diff --git a/packages/app/src/firebaseApp.ts b/packages/app/src/firebaseApp.ts index 8f83d599890..9e7f52f0d89 100644 --- a/packages/app/src/firebaseApp.ts +++ b/packages/app/src/firebaseApp.ts @@ -29,7 +29,8 @@ import { ComponentContainer, Component, ComponentType, - Name + Name, + InstantiationMode } from '@firebase/component'; import { AppError, ERROR_FACTORY } from './errors'; import { DEFAULT_ENTRY_NAME } from './constants'; @@ -122,8 +123,17 @@ export class FirebaseAppImpl implements FirebaseApp { ): FirebaseService { this.checkDestroyed_(); + // Initialize instance if InstatiationMode is `EXPLICIT`. + const provider = this.container.getProvider(name as Name); + if ( + !provider.isInitialized() && + provider.getComponent()?.instantiationMode === InstantiationMode.EXPLICIT + ) { + provider.initialize(); + } + // getImmediate will always succeed because _getService is only called for registered components. - return (this.container.getProvider(name as Name).getImmediate({ + return (provider.getImmediate({ identifier: instanceIdentifier }) as unknown) as FirebaseService; } diff --git a/packages/app/test/clientLogger.test.ts b/packages/app/test/clientLogger.test.ts index d224f2b9b91..c5e1b54f363 100644 --- a/packages/app/test/clientLogger.test.ts +++ b/packages/app/test/clientLogger.test.ts @@ -16,7 +16,10 @@ */ import { FirebaseNamespace, VersionService } from '@firebase/app-types'; -import { _FirebaseNamespace } from '@firebase/app-types/private'; +import { + _FirebaseNamespace, + FirebaseService +} from '@firebase/app-types/private'; import { createFirebaseNamespace } from '../src/firebaseNamespace'; import { expect } from 'chai'; import { spy as Spy } from 'sinon'; @@ -29,7 +32,7 @@ declare module '@firebase/component' { interface NameServiceMapping { 'vs1': VersionService; 'vs2': VersionService; - 'test-shell': Promise; + 'test-shell': FirebaseService; } } @@ -51,7 +54,7 @@ describe('User Log Methods', () => { (firebase as _FirebaseNamespace).INTERNAL.registerComponent( new Component( 'test-shell', - async () => { + () => { const logger = new Logger('@firebase/logger-test'); logger.warn('hello'); expect(warnSpy.called).to.be.true; @@ -67,6 +70,7 @@ describe('User Log Methods', () => { expect(infoSpy.called).to.be.true; logger.log('hi'); expect(logSpy.called).to.be.true; + return {} as FirebaseService; }, ComponentType.PUBLIC ) @@ -81,7 +85,7 @@ describe('User Log Methods', () => { (firebase as _FirebaseNamespace).INTERNAL.registerComponent( new Component( 'test-shell', - async () => { + () => { const logger = new Logger('@firebase/logger-test'); (firebase as _FirebaseNamespace).onLog(logData => { result = logData; @@ -92,6 +96,7 @@ describe('User Log Methods', () => { expect(result.args).to.deep.equal(['hi']); expect(result.type).to.equal('@firebase/logger-test'); expect(infoSpy.called).to.be.true; + return {} as FirebaseService; }, ComponentType.PUBLIC ) diff --git a/packages/app/test/firebaseApp.test.ts b/packages/app/test/firebaseApp.test.ts index da16c6c8411..9bffe96e211 100644 --- a/packages/app/test/firebaseApp.test.ts +++ b/packages/app/test/firebaseApp.test.ts @@ -29,7 +29,11 @@ import { createFirebaseNamespace } from '../src/firebaseNamespace'; import { createFirebaseNamespaceLite } from '../src/lite/firebaseNamespaceLite'; import { expect } from 'chai'; import { stub } from 'sinon'; -import { Component, ComponentType } from '@firebase/component'; +import { + Component, + ComponentType, + InstantiationMode +} from '@firebase/component'; import './setup'; executeFirebaseTests(); @@ -80,6 +84,67 @@ function executeFirebaseTests(): void { expect(service).to.eq((firebase as any).test()); }); + it('does not instantiate explicit components unless called explicitly', () => { + firebase.initializeApp({}); + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('test').setInstantiationMode( + InstantiationMode.EXPLICIT + ) + ); + + let explicitService; + + // Expect getImmediate in a consuming component to return null. + const consumerComponent = new Component( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'consumer' as any, + container => { + explicitService = container + .getProvider('test' as any) + .getImmediate({ optional: true }); + return new TestService(container.getProvider('app').getImmediate()); + }, + ComponentType.PUBLIC + ); + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + consumerComponent + ); + + (firebase as any).consumer(); + expect(explicitService).to.be.null; + }); + + it('does instantiate explicit components when called explicitly', () => { + firebase.initializeApp({}); + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('test').setInstantiationMode( + InstantiationMode.EXPLICIT + ) + ); + + let explicitService; + + // Expect getImmediate in a consuming component to return the service. + const consumerComponent = new Component( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'consumer' as any, + container => { + explicitService = container + .getProvider('test' as any) + .getImmediate({ optional: true }); + return new TestService(container.getProvider('app').getImmediate()); + }, + ComponentType.PUBLIC + ); + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + consumerComponent + ); + + (firebase as any).test(); + (firebase as any).consumer(); + expect(explicitService).to.not.be.null; + }); + it(`creates a new instance of a service after removing the existing instance`, () => { const app = firebase.initializeApp({}); (firebase as _FirebaseNamespace).INTERNAL.registerComponent( diff --git a/packages/app/test/platformLogger.test.ts b/packages/app/test/platformLogger.test.ts index 5aaad524942..eeae182eef0 100644 --- a/packages/app/test/platformLogger.test.ts +++ b/packages/app/test/platformLogger.test.ts @@ -16,7 +16,10 @@ */ import { FirebaseNamespace, VersionService } from '@firebase/app-types'; -import { _FirebaseNamespace } from '@firebase/app-types/private'; +import { + FirebaseService, + _FirebaseNamespace +} from '@firebase/app-types/private'; import { createFirebaseNamespace } from '../src/firebaseNamespace'; import { expect } from 'chai'; import './setup'; @@ -33,7 +36,7 @@ declare module '@firebase/component' { interface NameServiceMapping { 'vs1': VersionService; 'vs2': VersionService; - 'test-shell': Promise; + 'test-shell': FirebaseService; } } @@ -75,14 +78,15 @@ describe('Platform Logger Service', () => { (firebase as _FirebaseNamespace).INTERNAL.registerComponent( new Component( 'test-shell', - async (container: ComponentContainer) => { + (container: ComponentContainer) => { const platformLoggerProvider = container.getProvider( 'platform-logger' ); - const platformLogger = (await platformLoggerProvider.get()) as PlatformLoggerService; + const platformLogger = platformLoggerProvider.getImmediate() as PlatformLoggerService; const platformInfoString = platformLogger.getPlatformInfoString(); expect(platformInfoString).to.include(`fire-core/${appVersion}`); expect(platformInfoString).to.include('fire-js/'); + return {} as FirebaseService; }, ComponentType.PUBLIC ) @@ -96,16 +100,17 @@ describe('Platform Logger Service', () => { (firebase as _FirebaseNamespace).INTERNAL.registerComponent( new Component( 'test-shell', - async (container: ComponentContainer) => { + (container: ComponentContainer) => { const platformLoggerProvider = container.getProvider( 'platform-logger' ); - const platformLogger = (await platformLoggerProvider.get()) as PlatformLoggerService; + const platformLogger = platformLoggerProvider.getImmediate() as PlatformLoggerService; const platformInfoString = platformLogger.getPlatformInfoString(); expect(platformInfoString).to.include( `fire-core-node/${appVersion}` ); expect(platformInfoString).to.include('fire-js/'); + return {} as FirebaseService; }, ComponentType.PUBLIC ) @@ -123,14 +128,15 @@ describe('Platform Logger Service', () => { (firebase as _FirebaseNamespace).INTERNAL.registerComponent( new Component( 'test-shell', - async (container: ComponentContainer) => { + (container: ComponentContainer) => { const platformLoggerProvider = container.getProvider( 'platform-logger' ); - const platformLogger = (await platformLoggerProvider.get()) as PlatformLoggerService; + const platformLogger = platformLoggerProvider.getImmediate() as PlatformLoggerService; const platformInfoString = platformLogger.getPlatformInfoString(); expect(platformInfoString).to.include('fire-analytics/1.2.3'); expect(platformInfoString).to.include('fire-js/'); + return {} as FirebaseService; }, ComponentType.PUBLIC ) diff --git a/packages/auth-interop-types/CHANGELOG.md b/packages/auth-interop-types/CHANGELOG.md new file mode 100644 index 00000000000..ad6b6d8df17 --- /dev/null +++ b/packages/auth-interop-types/CHANGELOG.md @@ -0,0 +1,8 @@ +# @firebase/auth-interop-types + +## 0.1.6 +### Patch Changes + + + +- [`3f370215a`](https://github.com/firebase/firebase-js-sdk/commit/3f370215aa571db6b41b92a7d8a9aaad2ea0ecd0) [#4808](https://github.com/firebase/firebase-js-sdk/pull/4808) (fixes [#4789](https://github.com/firebase/firebase-js-sdk/issues/4789)) - Update peerDependencies diff --git a/packages/auth-interop-types/package.json b/packages/auth-interop-types/package.json index 9dd6e6ec114..0b2e69a08af 100644 --- a/packages/auth-interop-types/package.json +++ b/packages/auth-interop-types/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/auth-interop-types", - "version": "0.1.5", + "version": "0.1.6", "description": "@firebase/auth interop Types", "author": "Firebase (https://firebase.google.com/)", "license": "Apache-2.0", diff --git a/packages/auth-types/CHANGELOG.md b/packages/auth-types/CHANGELOG.md index e24e4d32930..7486dcc6870 100644 --- a/packages/auth-types/CHANGELOG.md +++ b/packages/auth-types/CHANGELOG.md @@ -1,8 +1,13 @@ # @firebase/auth-types -## 0.10.2 +## 0.10.3 + ### Patch Changes +- [`3f370215a`](https://github.com/firebase/firebase-js-sdk/commit/3f370215aa571db6b41b92a7d8a9aaad2ea0ecd0) [#4808](https://github.com/firebase/firebase-js-sdk/pull/4808) (fixes [#4789](https://github.com/firebase/firebase-js-sdk/issues/4789)) - Update peerDependencies +## 0.10.2 + +### Patch Changes -- [`4ab5a9ce5`](https://github.com/firebase/firebase-js-sdk/commit/4ab5a9ce5b6256a95d745f6dc40a5e5ddd2301f2) [#4481](https://github.com/firebase/firebase-js-sdk/pull/4481) - Add emulator methods to auth-types. +- [`4ab5a9ce5`](https://github.com/firebase/firebase-js-sdk/commit/4ab5a9ce5b6256a95d745f6dc40a5e5ddd2301f2) [#4481](https://github.com/firebase/firebase-js-sdk/pull/4481) - Add emulator methods to auth-types. diff --git a/packages/auth-types/package.json b/packages/auth-types/package.json index 724d1988904..f3f0c7fa93b 100644 --- a/packages/auth-types/package.json +++ b/packages/auth-types/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/auth-types", - "version": "0.10.2", + "version": "0.10.3", "description": "@firebase/auth Types", "author": "Firebase (https://firebase.google.com/)", "license": "Apache-2.0", diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index bb626e9fb31..a539aaabc3b 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -1,5 +1,12 @@ # @firebase/auth +## 0.16.5 + +### Patch Changes + +- Updated dependencies [[`3f370215a`](https://github.com/firebase/firebase-js-sdk/commit/3f370215aa571db6b41b92a7d8a9aaad2ea0ecd0)]: + - @firebase/auth-types@0.10.3 + ## 0.16.4 ### Patch Changes diff --git a/packages/auth/package.json b/packages/auth/package.json index f7bde6fa3a3..49b5d3ad4ee 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/auth", - "version": "0.16.4", + "version": "0.16.5", "main": "dist/auth.js", "browser": "dist/auth.esm.js", "module": "dist/auth.esm.js", @@ -21,7 +21,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@firebase/auth-types": "0.10.2" + "@firebase/auth-types": "0.10.3" }, "devDependencies": { "firebase-tools": "9.1.0", diff --git a/packages/auth/src/auth.js b/packages/auth/src/auth.js index e2863a8dd1e..2efcae9d8dd 100644 --- a/packages/auth/src/auth.js +++ b/packages/auth/src/auth.js @@ -339,7 +339,7 @@ fireauth.Auth.prototype.emitEmulatorWarning_ = function(disableBanner) { ele.style.width = '100%'; ele.style.backgroundColor = '#ffffff'; ele.style.border = '.1em solid #000000'; - ele.style.color = '#ff0000'; + ele.style.color = '#b50000'; ele.style.bottom = '0px'; ele.style.left = '0px'; ele.style.margin = '0px'; diff --git a/packages/component/CHANGELOG.md b/packages/component/CHANGELOG.md index edfb4f9bc5f..2d966852547 100644 --- a/packages/component/CHANGELOG.md +++ b/packages/component/CHANGELOG.md @@ -1,5 +1,16 @@ # @firebase/component +## 0.5.0 + +### Minor Changes + +- [`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae) [#4866](https://github.com/firebase/firebase-js-sdk/pull/4866) - Support onInit callback in provider + +### Patch Changes + +- Updated dependencies [[`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/util@1.1.0 + ## 0.4.1 ### Patch Changes diff --git a/packages/component/package.json b/packages/component/package.json index dfa412e7ae6..ad593c89fbe 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/component", - "version": "0.4.1", + "version": "0.5.0", "description": "Firebase Component Platform", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", @@ -23,7 +23,7 @@ "test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.ts --config ../../config/mocharc.node.js" }, "dependencies": { - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages/component/src/provider.test.ts b/packages/component/src/provider.test.ts index 49acaf7cbd1..13b14f8a42d 100644 --- a/packages/component/src/provider.test.ts +++ b/packages/component/src/provider.test.ts @@ -143,6 +143,63 @@ describe('Provider', () => { expect((provider as any).instances.size).to.equal(1); return expect(servicePromise).to.eventually.deep.equal({ test: true }); }); + + it('invokes onInit callbacks synchronously', () => { + provider.setComponent( + getFakeComponent( + 'test', + () => ({ test: true }), + false, + InstantiationMode.EXPLICIT + ) + ); + const callback1 = fake(); + provider.onInit(callback1); + + provider.initialize(); + expect(callback1).to.have.been.calledOnce; + }); + }); + + describe('onInit', () => { + it('registers onInit callbacks', () => { + provider.setComponent( + getFakeComponent( + 'test', + () => ({ test: true }), + false, + InstantiationMode.EXPLICIT + ) + ); + const callback1 = fake(); + const callback2 = fake(); + provider.onInit(callback1); + provider.onInit(callback2); + + provider.initialize(); + expect(callback1).to.have.been.calledOnce; + expect(callback2).to.have.been.calledOnce; + }); + + it('returns a function to unregister the callback', () => { + provider.setComponent( + getFakeComponent( + 'test', + () => ({ test: true }), + false, + InstantiationMode.EXPLICIT + ) + ); + const callback1 = fake(); + const callback2 = fake(); + provider.onInit(callback1); + const unregsiter = provider.onInit(callback2); + unregsiter(); + + provider.initialize(); + expect(callback1).to.have.been.calledOnce; + expect(callback2).to.not.have.been.called; + }); }); describe('Provider (multipleInstances = false)', () => { diff --git a/packages/component/src/provider.ts b/packages/component/src/provider.ts index e591267a032..d5a171b6c3a 100644 --- a/packages/component/src/provider.ts +++ b/packages/component/src/provider.ts @@ -22,7 +22,8 @@ import { InitializeOptions, InstantiationMode, Name, - NameServiceMapping + NameServiceMapping, + OnInitCallBack } from './types'; import { Component } from './component'; @@ -37,6 +38,7 @@ export class Provider { string, Deferred > = new Map(); + private onInitCallbacks: Set> = new Set(); constructor( private readonly name: T, @@ -250,9 +252,44 @@ export class Provider { instanceDeferred.resolve(instance); } } + + this.invokeOnInitCallbacks(instance, normalizedIdentifier); + return instance; } + /** + * + * @param callback - a function that will be invoked after the provider has been initialized by calling provider.initialize(). + * The function is invoked SYNCHRONOUSLY, so it should not execute any longrunning tasks in order to not block the program. + * + * @returns a function to unregister the callback + */ + onInit(callback: OnInitCallBack): () => void { + this.onInitCallbacks.add(callback); + + return () => { + this.onInitCallbacks.delete(callback); + }; + } + + /** + * Invoke onInit callbacks synchronously + * @param instance the service instance` + */ + private invokeOnInitCallbacks( + instance: NameServiceMapping[T], + identifier: string + ): void { + for (const callback of this.onInitCallbacks) { + try { + callback(instance, identifier); + } catch { + // ignore errors in the onInit callback + } + } + } + private getOrInitializeService({ instanceIdentifier, options = {} diff --git a/packages/component/src/types.ts b/packages/component/src/types.ts index eb5c26fb6da..fee0692314c 100644 --- a/packages/component/src/types.ts +++ b/packages/component/src/types.ts @@ -75,3 +75,8 @@ export interface NameServiceMapping {} export type Name = keyof NameServiceMapping; export type Service = NameServiceMapping[Name]; + +export type OnInitCallBack = ( + instance: NameServiceMapping[T], + identifier: string +) => void; diff --git a/packages/database/CHANGELOG.md b/packages/database/CHANGELOG.md index 41941562143..be8f1733d8f 100644 --- a/packages/database/CHANGELOG.md +++ b/packages/database/CHANGELOG.md @@ -1,5 +1,40 @@ # Unreleased +## 0.10.1 + +### Patch Changes + +- [`5b202f852`](https://github.com/firebase/firebase-js-sdk/commit/5b202f852ca68b35b06b0ea17e4b6b8c446c651c) [#4864](https://github.com/firebase/firebase-js-sdk/pull/4864) - Fixed an issue that could cause `once()` to fire more than once if the value was modified inside its callback. + +## 0.10.0 + +### Minor Changes + +- [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467) [#4792](https://github.com/firebase/firebase-js-sdk/pull/4792) - Add mockUserToken support for database emulator. + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/util@1.1.0 + +## 0.9.12 + +### Patch Changes + +- [`8d63eacf9`](https://github.com/firebase/firebase-js-sdk/commit/8d63eacf964c6e6b3b8ffe06bf682844ee430fbc) [#4832](https://github.com/firebase/firebase-js-sdk/pull/4832) (fixes [#4818](https://github.com/firebase/firebase-js-sdk/issues/4818)) - Fixes an issue that prevented the SDK from firing cancel events for Rules violations. + +* [`d422436d1`](https://github.com/firebase/firebase-js-sdk/commit/d422436d1d83f82aee8028e3a24c8e18d9d7c098) [#4828](https://github.com/firebase/firebase-js-sdk/pull/4828) (fixes [#4811](https://github.com/firebase/firebase-js-sdk/issues/4811)) - Fixes a regression introduced with 8.4.1 that broke `useEmulator()`. + +## 0.9.11 + +### Patch Changes + +- [`191184eb4`](https://github.com/firebase/firebase-js-sdk/commit/191184eb454109bff9198274fc416664b126d7ec) [#4801](https://github.com/firebase/firebase-js-sdk/pull/4801) - Fixes an internal conflict when using v8 and v9 SDKs in the same package. + +- Updated dependencies [[`3f370215a`](https://github.com/firebase/firebase-js-sdk/commit/3f370215aa571db6b41b92a7d8a9aaad2ea0ecd0)]: + - @firebase/auth-interop-types@0.1.6 + ## 0.9.10 ### Patch Changes diff --git a/packages/database/exp/index.node.ts b/packages/database/exp/index.node.ts index 99ba24010b3..dee46010eaa 100644 --- a/packages/database/exp/index.node.ts +++ b/packages/database/exp/index.node.ts @@ -15,8 +15,14 @@ * limitations under the License. */ +import { Client } from 'faye-websocket'; + +import { setWebSocketImpl } from '../src/realtime/WebSocketConnection'; + import { registerDatabase } from './register'; +setWebSocketImpl(Client); + export * from './api'; registerDatabase('node'); diff --git a/packages/database/exp/register.ts b/packages/database/exp/register.ts index 45aafe51fa3..86ae47e876a 100644 --- a/packages/database/exp/register.ts +++ b/packages/database/exp/register.ts @@ -15,11 +15,16 @@ * limitations under the License. */ -// eslint-disable-next-line import/no-extraneous-dependencies -import { _registerComponent, registerVersion } from '@firebase/app-exp'; +import { + _registerComponent, + registerVersion, + SDK_VERSION + // eslint-disable-next-line import/no-extraneous-dependencies +} from '@firebase/app-exp'; import { Component, ComponentType } from '@firebase/component'; import { name, version } from '../package.json'; +import { setSDKVersion } from '../src/core/version'; import { FirebaseDatabase, repoManagerDatabaseFromApp @@ -32,13 +37,20 @@ declare module '@firebase/component' { } export function registerDatabase(variant?: string): void { + setSDKVersion(SDK_VERSION); _registerComponent( new Component( 'database-exp', (container, { instanceIdentifier: url }) => { const app = container.getProvider('app-exp').getImmediate()!; const authProvider = container.getProvider('auth-internal'); - return repoManagerDatabaseFromApp(app, authProvider, url); + const appCheckProvider = container.getProvider('app-check-internal'); + return repoManagerDatabaseFromApp( + app, + authProvider, + appCheckProvider, + url + ); }, ComponentType.PUBLIC ).setMultipleInstances(true) diff --git a/packages/database/index.node.ts b/packages/database/index.node.ts index 277365da490..fd0fd6b1358 100644 --- a/packages/database/index.node.ts +++ b/packages/database/index.node.ts @@ -86,8 +86,10 @@ export function registerDatabase(instance: FirebaseNamespace) { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app').getImmediate(); const authProvider = container.getProvider('auth-internal'); + const appCheckProvider = container.getProvider('app-check-internal'); + return new Database( - repoManagerDatabaseFromApp(app, authProvider, url), + repoManagerDatabaseFromApp(app, authProvider, appCheckProvider, url), app ); }, @@ -123,8 +125,10 @@ try { // @firebase/app when used together with the js sdk. More detail: // https://github.com/firebase/firebase-js-sdk/issues/1696#issuecomment-501546596 // eslint-disable-next-line import/no-extraneous-dependencies, @typescript-eslint/no-require-imports - const firebase = require('@firebase/app').default; - registerDatabase(firebase); + const firebase = require('@firebase/app').default; // Only present for v8, undefined for v9 (should skip). + if (firebase) { + registerDatabase(firebase); + } } catch (err) { // catch and ignore 'MODULE_NOT_FOUND' error in firebase-admin context // we can safely ignore this error because RTDB in firebase-admin works without @firebase/app diff --git a/packages/database/index.ts b/packages/database/index.ts index ee0d0363b44..23e2bf6ded8 100644 --- a/packages/database/index.ts +++ b/packages/database/index.ts @@ -28,8 +28,9 @@ import { Database } from './src/api/Database'; import * as INTERNAL from './src/api/internal'; import { DataSnapshot, Query, Reference } from './src/api/Reference'; import * as TEST_ACCESS from './src/api/test_access'; +import { enableLogging } from './src/core/util/util'; import { setSDKVersion } from './src/core/version'; -import { enableLogging, repoManagerDatabaseFromApp } from './src/exp/Database'; +import { repoManagerDatabaseFromApp } from './src/exp/Database'; const ServerValue = Database.ServerValue; @@ -46,8 +47,10 @@ export function registerDatabase(instance: FirebaseNamespace) { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app').getImmediate(); const authProvider = container.getProvider('auth-internal'); + const appCheckProvider = container.getProvider('app-check-internal'); + return new Database( - repoManagerDatabaseFromApp(app, authProvider, url), + repoManagerDatabaseFromApp(app, authProvider, appCheckProvider, url), app ); }, diff --git a/packages/database/package.json b/packages/database/package.json index 6259e919cb8..fe35f603da0 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/database", - "version": "0.9.10", + "version": "0.10.1", "description": "", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.node.cjs.js", @@ -38,14 +38,14 @@ "dependencies": { "@firebase/database-types": "0.7.2", "@firebase/logger": "0.2.6", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", - "@firebase/auth-interop-types": "0.1.5", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", + "@firebase/auth-interop-types": "0.1.6", "faye-websocket": "0.11.3", "tslib": "^2.1.0" }, "devDependencies": { - "@firebase/app": "0.6.20", + "@firebase/app": "0.6.22", "@firebase/app-types": "0.6.2", "rollup": "2.35.1", "rollup-plugin-typescript2": "0.29.0", diff --git a/packages/database/src/api/Database.ts b/packages/database/src/api/Database.ts index 0282070c616..5428fe2e8e1 100644 --- a/packages/database/src/api/Database.ts +++ b/packages/database/src/api/Database.ts @@ -18,10 +18,13 @@ import { FirebaseApp } from '@firebase/app-types'; import { FirebaseService } from '@firebase/app-types/private'; -import { validateArgCount, Compat } from '@firebase/util'; +import { + validateArgCount, + Compat, + EmulatorMockTokenOptions +} from '@firebase/util'; import { - FirebaseDatabase as ExpDatabase, goOnline, useDatabaseEmulator, goOffline, @@ -33,6 +36,14 @@ import { import { Reference } from './Reference'; +// TODO: revert to import {FirebaseDatabase as ExpDatabase} from '@firebase/database' once modular SDK goes GA +/** + * This is a workaround for an issue in the no-modular '@firebase/database' where its typings + * reference types from `@firebase/app-exp`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ExpDatabase = any; + /** * Class representing a firebase database. */ @@ -58,9 +69,16 @@ export class Database implements FirebaseService, Compat { * * @param host - the emulator host (ex: localhost) * @param port - the emulator port (ex: 8080) + * @param options.mockUserToken - the mock auth token to use for unit testing Security Rules */ - useEmulator(host: string, port: number): void { - useDatabaseEmulator(this._delegate, host, port); + useEmulator( + host: string, + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions; + } = {} + ): void { + useDatabaseEmulator(this._delegate, host, port, options); } /** diff --git a/packages/database/src/api/Reference.ts b/packages/database/src/api/Reference.ts index ed733f116ef..4bc9b63365b 100644 --- a/packages/database/src/api/Reference.ts +++ b/packages/database/src/api/Reference.ts @@ -26,7 +26,6 @@ import { import { OnDisconnect as ExpOnDisconnect, - DataSnapshot as ExpDataSnapshot, off, onChildAdded, onChildChanged, @@ -54,8 +53,6 @@ import { setPriority, push, runTransaction, - Query as ExpQuery, - Reference as ExpReference, _QueryImpl, _ReferenceImpl, child @@ -75,6 +72,19 @@ import { Database } from './Database'; import { OnDisconnect } from './onDisconnect'; import { TransactionResult } from './TransactionResult'; +// TODO: revert to import { DataSnapshot as ExpDataSnapshot, Query as ExpQuery, +// Reference as ExpReference,} from '../../exp/index'; once the modular SDK goes GA +/** + * This is part of a workaround for an issue in the no-modular '@firebase/database' where its typings + * reference types from `@firebase/app-exp`. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type ExpDataSnapshot = any; +type ExpQuery = any; +type ExpReference = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ + /** * Class representing a firebase data snapshot. It wraps a SnapshotNode and * surfaces the public methods (val, forEach, etc.) we want to expose. @@ -316,7 +326,7 @@ export class Query implements Compat { validateCallback('Query.once', 'callback', callback, true); const ret = Query.getCancelAndContextArgs_( - 'Query.on', + 'Query.once', failureCallbackOrContext, context ); diff --git a/packages/database/src/api/internal.ts b/packages/database/src/api/internal.ts index cd70b7bb8c2..ca3344d2440 100644 --- a/packages/database/src/api/internal.ts +++ b/packages/database/src/api/internal.ts @@ -132,7 +132,13 @@ export function initStandalone({ return { instance: new Database( - _repoManagerDatabaseFromApp(app, authProvider, url, nodeAdmin), + _repoManagerDatabaseFromApp( + app, + authProvider, + /* appCheckProvider= */ undefined, + url, + nodeAdmin + ), app ) as types.Database, namespace diff --git a/packages/database/src/api/onDisconnect.ts b/packages/database/src/api/onDisconnect.ts index 6cee80e8e56..3a655f08337 100644 --- a/packages/database/src/api/onDisconnect.ts +++ b/packages/database/src/api/onDisconnect.ts @@ -17,10 +17,17 @@ import { validateArgCount, validateCallback, Compat } from '@firebase/util'; -import { OnDisconnect as ExpOnDisconnect } from '../../exp/index'; import { Indexable } from '../core/util/misc'; import { warn } from '../core/util/util'; +// TODO: revert to import { OnDisconnect as ExpOnDisconnect } from '../../exp/index'; once the modular SDK goes GA +/** + * This is a workaround for an issue in the no-modular '@firebase/database' where its typings + * reference types from `@firebase/app-exp`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ExpOnDisconnect = any; + export class OnDisconnect implements Compat { constructor(readonly _delegate: ExpOnDisconnect) {} diff --git a/packages/database/src/core/AppCheckTokenProvider.ts b/packages/database/src/core/AppCheckTokenProvider.ts new file mode 100644 index 00000000000..5856fa4d06a --- /dev/null +++ b/packages/database/src/core/AppCheckTokenProvider.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2021 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 { + AppCheckInternalComponentName, + AppCheckTokenListener, + AppCheckTokenResult, + FirebaseAppCheckInternal +} from '@firebase/app-check-interop-types'; +import { Provider } from '@firebase/component'; + +import { warn } from './util/util'; + +/** + * Abstraction around AppCheck's token fetching capabilities. + */ +export class AppCheckTokenProvider { + private appCheck?: FirebaseAppCheckInternal; + constructor( + private appName_: string, + private appCheckProvider?: Provider + ) { + this.appCheck = appCheckProvider?.getImmediate({ optional: true }); + if (!this.appCheck) { + appCheckProvider?.get().then(appCheck => (this.appCheck = appCheck)); + } + } + + getToken(forceRefresh?: boolean): Promise { + if (!this.appCheck) { + return new Promise((resolve, reject) => { + // Support delayed initialization of FirebaseAppCheck. This allows our + // customers to initialize the RTDB SDK before initializing Firebase + // AppCheck and ensures that all requests are authenticated if a token + // becomes available before the timoeout below expires. + setTimeout(() => { + if (this.appCheck) { + this.getToken(forceRefresh).then(resolve, reject); + } else { + resolve(null); + } + }, 0); + }); + } + return this.appCheck.getToken(forceRefresh); + } + + addTokenChangeListener(listener: AppCheckTokenListener) { + this.appCheckProvider + ?.get() + .then(appCheck => appCheck.addTokenListener(listener)); + } + + notifyForInvalidToken(): void { + warn( + `Provided AppCheck credentials for the app named "${this.appName_}" ` + + 'are invalid. This usually indicates your app was not initialized correctly.' + ); + } +} diff --git a/packages/database/src/core/AuthTokenProvider.ts b/packages/database/src/core/AuthTokenProvider.ts index 1662eea2770..46ccf9b0a68 100644 --- a/packages/database/src/core/AuthTokenProvider.ts +++ b/packages/database/src/core/AuthTokenProvider.ts @@ -36,6 +36,7 @@ export interface AuthTokenProvider { */ export class FirebaseAuthTokenProvider implements AuthTokenProvider { private auth_: FirebaseAuthInternal | null = null; + constructor( private appName_: string, private firebaseOptions_: object, @@ -43,13 +44,25 @@ export class FirebaseAuthTokenProvider implements AuthTokenProvider { ) { this.auth_ = authProvider_.getImmediate({ optional: true }); if (!this.auth_) { - authProvider_.get().then(auth => (this.auth_ = auth)); + authProvider_.onInit(auth => (this.auth_ = auth)); } } getToken(forceRefresh: boolean): Promise { if (!this.auth_) { - return Promise.resolve(null); + return new Promise((resolve, reject) => { + // Support delayed initialization of FirebaseAuth. This allows our + // customers to initialize the RTDB SDK before initializing Firebase + // Auth and ensures that all requests are authenticated if a token + // becomes available before the timoeout below expires. + setTimeout(() => { + if (this.auth_) { + this.getToken(forceRefresh).then(resolve, reject); + } else { + resolve(null); + } + }, 0); + }); } return this.auth_.getToken(forceRefresh).catch(error => { @@ -70,7 +83,6 @@ export class FirebaseAuthTokenProvider implements AuthTokenProvider { if (this.auth_) { this.auth_.addAuthTokenListener(listener); } else { - setTimeout(() => listener(null), 0); this.authProvider_ .get() .then(auth => auth.addAuthTokenListener(listener)); @@ -109,20 +121,23 @@ export class FirebaseAuthTokenProvider implements AuthTokenProvider { } } -/* Auth token provider that the Admin SDK uses to connect to the Emulator. */ -export class EmulatorAdminTokenProvider implements AuthTokenProvider { - private static EMULATOR_AUTH_TOKEN = 'owner'; +/* AuthTokenProvider that supplies a constant token. Used by Admin SDK or mockUserToken with emulators. */ +export class EmulatorTokenProvider implements AuthTokenProvider { + /** A string that is treated as an admin access token by the RTDB emulator. Used by Admin SDK. */ + static OWNER = 'owner'; + + constructor(private accessToken: string) {} getToken(forceRefresh: boolean): Promise { return Promise.resolve({ - accessToken: EmulatorAdminTokenProvider.EMULATOR_AUTH_TOKEN + accessToken: this.accessToken }); } addTokenChangeListener(listener: (token: string | null) => void): void { // Invoke the listener immediately to match the behavior in Firebase Auth // (see packages/auth/src/auth.js#L1807) - listener(EmulatorAdminTokenProvider.EMULATOR_AUTH_TOKEN); + listener(this.accessToken); } removeTokenChangeListener(listener: (token: string | null) => void): void {} diff --git a/packages/database/src/core/PersistentConnection.ts b/packages/database/src/core/PersistentConnection.ts index e3cfa4c5322..62b27a37686 100644 --- a/packages/database/src/core/PersistentConnection.ts +++ b/packages/database/src/core/PersistentConnection.ts @@ -16,21 +16,22 @@ */ import { + assert, contains, + Deferred, isEmpty, - safeGet, - stringify, - assert, - isAdmin, - isValidFormat, isMobileCordova, - isReactNative, isNodeSdk, - Deferred + isReactNative, + isValidFormat, + safeGet, + stringify, + isAdmin } from '@firebase/util'; import { Connection } from '../realtime/Connection'; +import { AppCheckTokenProvider } from './AppCheckTokenProvider'; import { AuthTokenProvider } from './AuthTokenProvider'; import { RepoInfo } from './RepoInfo'; import { ServerActions } from './ServerActions'; @@ -50,7 +51,7 @@ const RECONNECT_DELAY_RESET_TIMEOUT = 30000; // Reset delay back to MIN_DELAY af const SERVER_KILL_INTERRUPT_REASON = 'server_kill'; // If auth fails repeatedly, we'll assume something is wrong and log a warning / back off. -const INVALID_AUTH_TOKEN_THRESHOLD = 3; +const INVALID_TOKEN_THRESHOLD = 3; interface ListenSpec { onComplete(s: string, p?: unknown): void; @@ -121,8 +122,10 @@ export class PersistentConnection extends ServerActions { } | null = null; private authToken_: string | null = null; + private appCheckToken_: string | null = null; private forceTokenRefresh_ = false; private invalidAuthTokenCount_ = 0; + private invalidAppCheckTokenCount_ = 0; private firstConnection_ = true; private lastConnectionAttemptTime_: number | null = null; @@ -152,6 +155,7 @@ export class PersistentConnection extends ServerActions { private onConnectStatus_: (a: boolean) => void, private onServerInfoUpdate_: (a: unknown) => void, private authTokenProvider_: AuthTokenProvider, + private appCheckTokenProvider_: AppCheckTokenProvider, private authOverride_?: object | null ) { super(); @@ -161,7 +165,6 @@ export class PersistentConnection extends ServerActions { 'Auth override specified in options, but not supported on non Node.js platforms' ); } - this.scheduleConnect_(0); VisibilityMonitor.getInstance().on('visible', this.onVisible_, this); @@ -190,6 +193,8 @@ export class PersistentConnection extends ServerActions { } get(query: QueryContext): Promise { + this.initConnection_(); + const deferred = new Deferred(); const request = { p: query._path.toString(), @@ -239,12 +244,15 @@ export class PersistentConnection extends ServerActions { return deferred.promise; } + listen( query: QueryContext, currentHashFn: () => string, tag: number | null, onComplete: (a: string, b: unknown) => void ) { + this.initConnection_(); + const queryId = query._queryIdentifier; const pathString = query._path.toString(); this.log_('Listen called for ' + pathString + ' ' + queryId); @@ -344,6 +352,7 @@ export class PersistentConnection extends ServerActions { } } } + refreshAuthToken(token: string) { this.authToken_ = token; this.log_('Auth token refreshed'); @@ -372,6 +381,21 @@ export class PersistentConnection extends ServerActions { } } + refreshAppCheckToken(token: string | null) { + this.appCheckToken_ = token; + this.log_('App check token refreshed'); + if (this.appCheckToken_) { + this.tryAppCheck(); + } else { + //If we're connected we want to let the server know to unauthenticate us. + //If we're not connected, simply delete the credential so we dont become + // authenticated next time we connect. + if (this.connected_) { + this.sendRequest('unappeck', {}, () => {}); + } + } + } + /** * Attempts to authenticate with the given credentials. If the authentication attempt fails, it's triggered like * a auth revoked (the connection is closed). @@ -405,6 +429,33 @@ export class PersistentConnection extends ServerActions { ); } } + + /** + * Attempts to authenticate with the given token. If the authentication + * attempt fails, it's triggered like the token was revoked (the connection is + * closed). + */ + tryAppCheck() { + if (this.connected_ && this.appCheckToken_) { + this.sendRequest( + 'appcheck', + { 'token': this.appCheckToken_ }, + (res: { [k: string]: unknown }) => { + const status = res[/*status*/ 's'] as string; + const data = (res[/*data*/ 'd'] as string) || 'error'; + if (status === 'ok') { + this.invalidAppCheckTokenCount_ = 0; + } else { + this.onAppCheckRevoked_(status, data); + } + } + ); + } + } + + /** + * @inheritDoc + */ unlisten(query: QueryContext, tag: number | null) { const pathString = query._path.toString(); const queryId = query._queryIdentifier; @@ -439,11 +490,14 @@ export class PersistentConnection extends ServerActions { this.sendRequest(action, req); } + onDisconnectPut( pathString: string, data: unknown, onComplete?: (a: string, b: string) => void ) { + this.initConnection_(); + if (this.connected_) { this.sendOnDisconnect_('o', pathString, data, onComplete); } else { @@ -455,11 +509,14 @@ export class PersistentConnection extends ServerActions { }); } } + onDisconnectMerge( pathString: string, data: unknown, onComplete?: (a: string, b: string) => void ) { + this.initConnection_(); + if (this.connected_) { this.sendOnDisconnect_('om', pathString, data, onComplete); } else { @@ -471,10 +528,13 @@ export class PersistentConnection extends ServerActions { }); } } + onDisconnectCancel( pathString: string, onComplete?: (a: string, b: string) => void ) { + this.initConnection_(); + if (this.connected_) { this.sendOnDisconnect_('oc', pathString, null, onComplete); } else { @@ -506,6 +566,7 @@ export class PersistentConnection extends ServerActions { } }); } + put( pathString: string, data: unknown, @@ -514,6 +575,7 @@ export class PersistentConnection extends ServerActions { ) { this.putInternal('p', pathString, data, onComplete, hash); } + merge( pathString: string, data: unknown, @@ -530,6 +592,8 @@ export class PersistentConnection extends ServerActions { onComplete: (a: string, b: string | null) => void, hash?: string ) { + this.initConnection_(); + const request: { [k: string]: unknown } = { /*path*/ p: pathString, /*data*/ d: data @@ -581,6 +645,7 @@ export class PersistentConnection extends ServerActions { } }); } + reportStats(stats: { [k: string]: unknown }) { // If we're not connected, we just drop the stats. if (this.connected_) { @@ -641,6 +706,11 @@ export class PersistentConnection extends ServerActions { body[/*status code*/ 's'] as string, body[/* explanation */ 'd'] as string ); + } else if (action === 'apc') { + this.onAppCheckRevoked_( + body[/*status code*/ 's'] as string, + body[/* explanation */ 'd'] as string + ); } else if (action === 'sd') { this.onSecurityDebugPacket_(body); } else { @@ -686,6 +756,12 @@ export class PersistentConnection extends ServerActions { }, Math.floor(timeout)) as any; } + private initConnection_() { + if (!this.realtime_ && this.firstConnection_) { + this.scheduleConnect_(0); + } + } + private onVisible_(visible: boolean) { // NOTE: Tabbing away and back to a window will defeat our reconnect backoff, but I think that's fine. if ( @@ -764,7 +840,7 @@ export class PersistentConnection extends ServerActions { this.onConnectStatus_(false); } - private establishConnection_() { + private async establishConnection_() { if (this.shouldReconnect_()) { this.log_('Making a connection attempt'); this.lastConnectionAttemptTime_ = new Date().getTime(); @@ -773,7 +849,6 @@ export class PersistentConnection extends ServerActions { const onReady = this.onReady_.bind(this); const onDisconnect = this.onRealtimeDisconnect_.bind(this); const connId = this.id + ':' + PersistentConnection.nextConnectionId_++; - const self = this; const lastSessionId = this.lastSessionId; let canceled = false; let connection: Connection | null = null; @@ -801,42 +876,48 @@ export class PersistentConnection extends ServerActions { const forceRefresh = this.forceTokenRefresh_; this.forceTokenRefresh_ = false; - // First fetch auth token, and establish connection after fetching the token was successful - this.authTokenProvider_ - .getToken(forceRefresh) - .then(result => { - if (!canceled) { - log('getToken() completed. Creating connection.'); - self.authToken_ = result && result.accessToken; - connection = new Connection( - connId, - self.repoInfo_, - self.applicationId_, - onDataMessage, - onReady, - onDisconnect, - /* onKill= */ reason => { - warn(reason + ' (' + self.repoInfo_.toString() + ')'); - self.interrupt(SERVER_KILL_INTERRUPT_REASON); - }, - lastSessionId - ); - } else { - log('getToken() completed but was canceled'); - } - }) - .then(null, error => { - self.log_('Failed to get token: ' + error); - if (!canceled) { - if (this.repoInfo_.nodeAdmin) { - // This may be a critical error for the Admin Node.js SDK, so log a warning. - // But getToken() may also just have temporarily failed, so we still want to - // continue retrying. - warn(error); - } - closeFn(); + try { + // First fetch auth and app check token, and establish connection after + // fetching the token was successful + const [authToken, appCheckToken] = await Promise.all([ + this.authTokenProvider_.getToken(forceRefresh), + this.appCheckTokenProvider_.getToken(forceRefresh) + ]); + + if (!canceled) { + log('getToken() completed. Creating connection.'); + this.authToken_ = authToken && authToken.accessToken; + this.appCheckToken_ = appCheckToken && appCheckToken.token; + connection = new Connection( + connId, + this.repoInfo_, + this.applicationId_, + this.appCheckToken_, + this.authToken_, + onDataMessage, + onReady, + onDisconnect, + /* onKill= */ reason => { + warn(reason + ' (' + this.repoInfo_.toString() + ')'); + this.interrupt(SERVER_KILL_INTERRUPT_REASON); + }, + lastSessionId + ); + } else { + log('getToken() completed but was canceled'); + } + } catch (error) { + this.log_('Failed to get token: ' + error); + if (!canceled) { + if (this.repoInfo_.nodeAdmin) { + // This may be a critical error for the Admin Node.js SDK, so log a warning. + // But getToken() may also just have temporarily failed, so we still want to + // continue retrying. + warn(error); } - }); + closeFn(); + } + } } } @@ -932,7 +1013,7 @@ export class PersistentConnection extends ServerActions { // retry period since oauth tokens will report as "invalid" if they're // just expired. Plus there may be transient issues that resolve themselves. this.invalidAuthTokenCount_++; - if (this.invalidAuthTokenCount_ >= INVALID_AUTH_TOKEN_THRESHOLD) { + if (this.invalidAuthTokenCount_ >= INVALID_TOKEN_THRESHOLD) { // Set a long reconnect delay because recovery is unlikely this.reconnectDelay_ = RECONNECT_MAX_DELAY_FOR_ADMINS; @@ -943,6 +1024,23 @@ export class PersistentConnection extends ServerActions { } } + private onAppCheckRevoked_(statusCode: string, explanation: string) { + log('App check token revoked: ' + statusCode + '/' + explanation); + this.appCheckToken_ = null; + this.forceTokenRefresh_ = true; + // Note: We don't close the connection as the developer may not have + // enforcement enabled. The backend closes connections with enforcements. + if (statusCode === 'invalid_token' || statusCode === 'permission_denied') { + // We'll wait a couple times before logging the warning / increasing the + // retry period since oauth tokens will report as "invalid" if they're + // just expired. Plus there may be transient issues that resolve themselves. + this.invalidAppCheckTokenCount_++; + if (this.invalidAppCheckTokenCount_ >= INVALID_TOKEN_THRESHOLD) { + this.appCheckTokenProvider_.notifyForInvalidToken(); + } + } + } + private onSecurityDebugPacket_(body: { [k: string]: unknown }) { if (this.securityDebugCallback_) { this.securityDebugCallback_(body); @@ -958,6 +1056,7 @@ export class PersistentConnection extends ServerActions { private restoreState_() { //Re-authenticate ourselves if we have a credential stored. this.tryAuth(); + this.tryAppCheck(); // Puts depend on having received the corresponding data update from the server before they complete, so we must // make sure to send listens before puts. diff --git a/packages/database/src/core/ReadonlyRestClient.ts b/packages/database/src/core/ReadonlyRestClient.ts index 63615b6b003..6b5d6be843a 100644 --- a/packages/database/src/core/ReadonlyRestClient.ts +++ b/packages/database/src/core/ReadonlyRestClient.ts @@ -23,6 +23,7 @@ import { Deferred } from '@firebase/util'; +import { AppCheckTokenProvider } from './AppCheckTokenProvider'; import { AuthTokenProvider } from './AuthTokenProvider'; import { RepoInfo } from './RepoInfo'; import { ServerActions } from './ServerActions'; @@ -73,7 +74,8 @@ export class ReadonlyRestClient extends ServerActions { c: boolean, d: number | null ) => void, - private authTokenProvider_: AuthTokenProvider + private authTokenProvider_: AuthTokenProvider, + private appCheckTokenProvider_: AppCheckTokenProvider ) { super(); } @@ -186,64 +188,67 @@ export class ReadonlyRestClient extends ServerActions { ) { queryStringParameters['format'] = 'export'; - this.authTokenProvider_ - .getToken(/*forceRefresh=*/ false) - .then(authTokenData => { - const authToken = authTokenData && authTokenData.accessToken; - if (authToken) { - queryStringParameters['auth'] = authToken; - } + return Promise.all([ + this.authTokenProvider_.getToken(/*forceRefresh=*/ false), + this.appCheckTokenProvider_.getToken(/*forceRefresh=*/ false) + ]).then(([authToken, appCheckToken]) => { + if (authToken && authToken.accessToken) { + queryStringParameters['auth'] = authToken.accessToken; + } + if (appCheckToken && appCheckToken.token) { + queryStringParameters['ac'] = appCheckToken.token; + } - const url = - (this.repoInfo_.secure ? 'https://' : 'http://') + - this.repoInfo_.host + - pathString + - '?' + - 'ns=' + - this.repoInfo_.namespace + - querystring(queryStringParameters); - - this.log_('Sending REST request for ' + url); - const xhr = new XMLHttpRequest(); - xhr.onreadystatechange = () => { - if (callback && xhr.readyState === 4) { - this.log_( - 'REST Response for ' + url + ' received. status:', - xhr.status, - 'response:', - xhr.responseText - ); - let res = null; - if (xhr.status >= 200 && xhr.status < 300) { - try { - res = jsonEval(xhr.responseText); - } catch (e) { - warn( - 'Failed to parse JSON response for ' + - url + - ': ' + - xhr.responseText - ); - } - callback(null, res); - } else { - // 401 and 404 are expected. - if (xhr.status !== 401 && xhr.status !== 404) { - warn( - 'Got unsuccessful REST response for ' + - url + - ' Status: ' + - xhr.status - ); - } - callback(xhr.status); + const url = + (this.repoInfo_.secure ? 'https://' : 'http://') + + this.repoInfo_.host + + pathString + + '?' + + 'ns=' + + this.repoInfo_.namespace + + querystring(queryStringParameters); + + this.log_('Sending REST request for ' + url); + const xhr = new XMLHttpRequest(); + xhr.onreadystatechange = () => { + if (callback && xhr.readyState === 4) { + this.log_( + 'REST Response for ' + url + ' received. status:', + xhr.status, + 'response:', + xhr.responseText + ); + let res = null; + if (xhr.status >= 200 && xhr.status < 300) { + try { + res = jsonEval(xhr.responseText); + } catch (e) { + warn( + 'Failed to parse JSON response for ' + + url + + ': ' + + xhr.responseText + ); } - callback = null; + callback(null, res); + } else { + // 401 and 404 are expected. + if (xhr.status !== 401 && xhr.status !== 404) { + warn( + 'Got unsuccessful REST response for ' + + url + + ' Status: ' + + xhr.status + ); + } + callback(xhr.status); } - }; + callback = null; + } + }; - xhr.open('GET', url, /*asynchronous=*/ true); - xhr.send(); - }); + xhr.open('GET', url, /*asynchronous=*/ true); + xhr.send(); + }); } } diff --git a/packages/database/src/core/Repo.ts b/packages/database/src/core/Repo.ts index 0016a827bce..9479f620529 100644 --- a/packages/database/src/core/Repo.ts +++ b/packages/database/src/core/Repo.ts @@ -24,6 +24,7 @@ import { stringify } from '@firebase/util'; +import { AppCheckTokenProvider } from './AppCheckTokenProvider'; import { AuthTokenProvider } from './AuthTokenProvider'; import { PersistentConnection } from './PersistentConnection'; import { ReadonlyRestClient } from './ReadonlyRestClient'; @@ -187,7 +188,8 @@ export class Repo { constructor( public repoInfo_: RepoInfo, public forceRestClient_: boolean, - public authTokenProvider_: AuthTokenProvider + public authTokenProvider_: AuthTokenProvider, + public appCheckProvider_: AppCheckTokenProvider ) { // This key is intentionally not updated if RepoInfo is later changed or replaced this.key = this.repoInfo_.toURLString(); @@ -221,7 +223,8 @@ export function repoStart( ) => { repoOnDataUpdate(repo, pathString, data, isMerge, tag); }, - repo.authTokenProvider_ + repo.authTokenProvider_, + repo.appCheckProvider_ ); // Minor hack: Fire onConnect immediately, since there's no actual connection. @@ -259,6 +262,7 @@ export function repoStart( repoOnServerInfoUpdate(repo, updates); }, repo.authTokenProvider_, + repo.appCheckProvider_, authOverride ); @@ -269,6 +273,10 @@ export function repoStart( repo.server_.refreshAuthToken(token); }); + repo.appCheckProvider_.addTokenChangeListener(result => { + repo.server_.refreshAppCheckToken(result.token); + }); + // In the case of multiple Repos for the same repoInfo (i.e. there are multiple Firebase.Contexts being used), // we only want to create one StatsReporter. As such, we'll report stats over the first Repo created. repo.statsReporter_ = statsManagerGetOrCreateReporter( diff --git a/packages/database/src/core/ServerActions.ts b/packages/database/src/core/ServerActions.ts index ee5b3f32ff9..3bafba308b8 100644 --- a/packages/database/src/core/ServerActions.ts +++ b/packages/database/src/core/ServerActions.ts @@ -61,6 +61,12 @@ export abstract class ServerActions { */ refreshAuthToken(token: string) {} + /** + * Refreshes the app check token for the current connection. + * @param token The app check token + */ + refreshAppCheckToken(token: string) {} + onDisconnectPut( pathString: string, data: unknown, diff --git a/packages/database/src/core/util/util.ts b/packages/database/src/core/util/util.ts index 4ce187b755c..4f6a067efb1 100644 --- a/packages/database/src/core/util/util.ts +++ b/packages/database/src/core/util/util.ts @@ -26,8 +26,14 @@ import { } from '@firebase/util'; import { SessionStorage } from '../storage/storage'; -import { QueryContext } from '../view/EventRegistration'; +// TODO: revert to import { QueryContext } from '../view/EventRegistration'; once the modular SDK goes GA +/** + * This is part of a workaround for an issue in the no-modular '@firebase/database' where its typings + * reference types from `@firebase/app-exp`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type QueryContext = any; declare const window: Window; // eslint-disable-next-line @typescript-eslint/no-explicit-any declare const Windows: any; diff --git a/packages/database/src/exp/Database.ts b/packages/database/src/exp/Database.ts index 5f0ac58c144..ac1ed99900f 100644 --- a/packages/database/src/exp/Database.ts +++ b/packages/database/src/exp/Database.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { _FirebaseService, _getProvider, @@ -24,11 +25,16 @@ import { } from '@firebase/app-exp'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; -import { getModularInstance } from '@firebase/util'; +import { + getModularInstance, + createMockUserToken, + EmulatorMockTokenOptions +} from '@firebase/util'; +import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider'; import { AuthTokenProvider, - EmulatorAdminTokenProvider, + EmulatorTokenProvider, FirebaseAuthTokenProvider } from '../core/AuthTokenProvider'; import { Repo, repoInterrupt, repoResume, repoStart } from '../core/Repo'; @@ -74,7 +80,8 @@ let useRestClient = false; function repoManagerApplyEmulatorSettings( repo: Repo, host: string, - port: number + port: number, + tokenProvider?: AuthTokenProvider ): void { repo.repoInfo_ = new RepoInfo( `${host}:${port}`, @@ -86,8 +93,8 @@ function repoManagerApplyEmulatorSettings( repo.repoInfo_.includeNamespaceInQueryParams ); - if (repo.repoInfo_.nodeAdmin) { - repo.authTokenProvider_ = new EmulatorAdminTokenProvider(); + if (tokenProvider) { + repo.authTokenProvider_ = tokenProvider; } } @@ -98,6 +105,7 @@ function repoManagerApplyEmulatorSettings( export function repoManagerDatabaseFromApp( app: FirebaseApp, authProvider: Provider, + appCheckProvider?: Provider, url?: string, nodeAdmin?: boolean ): FirebaseDatabase { @@ -135,7 +143,7 @@ export function repoManagerDatabaseFromApp( const authTokenProvider = nodeAdmin && isEmulator - ? new EmulatorAdminTokenProvider() + ? new EmulatorTokenProvider(EmulatorTokenProvider.OWNER) : new FirebaseAuthTokenProvider(app.name, app.options, authProvider); validateUrl('Invalid Firebase Database URL', parsedUrl); @@ -146,7 +154,12 @@ export function repoManagerDatabaseFromApp( ); } - const repo = repoManagerCreateRepo(repoInfo, app, authTokenProvider); + const repo = repoManagerCreateRepo( + repoInfo, + app, + authTokenProvider, + new AppCheckTokenProvider(app.name, appCheckProvider) + ); return new FirebaseDatabase(repo, app); } @@ -174,7 +187,8 @@ function repoManagerDeleteRepo(repo: Repo, appName: string): void { function repoManagerCreateRepo( repoInfo: RepoInfo, app: FirebaseApp, - authTokenProvider: AuthTokenProvider + authTokenProvider: AuthTokenProvider, + appCheckProvider: AppCheckTokenProvider ): Repo { let appRepos = repos[app.name]; @@ -189,7 +203,7 @@ function repoManagerCreateRepo( 'Database initialized multiple times. Please make sure the format of the database URL matches with each database() call.' ); } - repo = new Repo(repoInfo, useRestClient, authTokenProvider); + repo = new Repo(repoInfo, useRestClient, authTokenProvider, appCheckProvider); appRepos[repoInfo.toURLString()] = repo; return repo; @@ -217,7 +231,7 @@ export class FirebaseDatabase implements _FirebaseService { /** @hideconstructor */ constructor( - private _repoInternal: Repo, + public _repoInternal: Repo, /** The FirebaseApp associated with this Realtime Database instance. */ readonly app: FirebaseApp ) {} @@ -286,11 +300,15 @@ export function getDatabase( * @param db - The instance to modify. * @param host - The emulator host (ex: localhost) * @param port - The emulator port (ex: 8080) + * @param options.mockUserToken - the mock auth token to use for unit testing Security Rules */ export function useDatabaseEmulator( db: FirebaseDatabase, host: string, - port: number + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions; + } = {} ): void { db = getModularInstance(db); db._checkNotDeleted('useEmulator'); @@ -299,8 +317,26 @@ export function useDatabaseEmulator( 'Cannot call useEmulator() after instance has already been initialized.' ); } + + const repo = db._repoInternal; + let tokenProvider: EmulatorTokenProvider | undefined = undefined; + if (repo.repoInfo_.nodeAdmin) { + if (options.mockUserToken) { + fatal( + 'mockUserToken is not supported by the Admin SDK. For client access with mock users, please use the "firebase" package instead of "firebase-admin".' + ); + } + tokenProvider = new EmulatorTokenProvider(EmulatorTokenProvider.OWNER); + } else if (options.mockUserToken) { + const token = createMockUserToken( + options.mockUserToken, + db.app.options.projectId + ); + tokenProvider = new EmulatorTokenProvider(token); + } + // Modify the repo to apply emulator settings - repoManagerApplyEmulatorSettings(db._repo, host, port); + repoManagerApplyEmulatorSettings(repo, host, port, tokenProvider); } /** diff --git a/packages/database/src/exp/Reference_impl.ts b/packages/database/src/exp/Reference_impl.ts index 79c359b6036..e7bdcc08168 100644 --- a/packages/database/src/exp/Reference_impl.ts +++ b/packages/database/src/exp/Reference_impl.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { assert, contains, getModularInstance, Deferred } from '@firebase/util'; +import { assert, getModularInstance, Deferred } from '@firebase/util'; import { Repo, @@ -870,16 +870,12 @@ export class ValueEventRegistration implements EventRegistration { } /** - * Represents the registration of 1 or more child_xxx events. - * - * Currently, it is always exactly 1 child_xxx event, but the idea is we might - * let you register a group of callbacks together in the future. + * Represents the registration of a child_x event. */ export class ChildEventRegistration implements EventRegistration { constructor( - private callbacks: { - [child: string]: CallbackContext; - } | null + private eventType: string, + private callbackContext: CallbackContext | null ) {} respondsTo(eventType: string): boolean { @@ -887,11 +883,11 @@ export class ChildEventRegistration implements EventRegistration { eventType === 'children_added' ? 'child_added' : eventType; eventToCheck = eventToCheck === 'children_removed' ? 'child_removed' : eventToCheck; - return contains(this.callbacks, eventToCheck); + return this.eventType === eventToCheck; } createCancelEvent(error: Error, path: Path): CancelEvent | null { - if (this.callbacks['cancel'].hasCancelCallback) { + if (this.callbackContext.hasCancelCallback) { return new CancelEvent(this, error, path); } else { return null; @@ -915,12 +911,11 @@ export class ChildEventRegistration implements EventRegistration { getEventRunner(eventData: CancelEvent | DataEvent): () => void { if (eventData.getEventType() === 'cancel') { - const cancelCB = this.callbacks['cancel']; - return () => cancelCB.onCancel((eventData as CancelEvent).error); + return () => + this.callbackContext.onCancel((eventData as CancelEvent).error); } else { - const cb = this.callbacks[(eventData as DataEvent).eventType]; return () => - cb.onValue( + this.callbackContext.onValue( (eventData as DataEvent).snapshot, (eventData as DataEvent).prevName ); @@ -929,42 +924,19 @@ export class ChildEventRegistration implements EventRegistration { matches(other: EventRegistration): boolean { if (other instanceof ChildEventRegistration) { - if (!this.callbacks || !other.callbacks) { - return true; - } else { - const otherKeys = Object.keys(other.callbacks); - const thisKeys = Object.keys(this.callbacks); - const otherCount = otherKeys.length; - const thisCount = thisKeys.length; - if (otherCount === thisCount) { - // If count is 1, do an exact match on eventType, if either is defined but null, it's a match. - // If event types don't match, not a match - // If count is not 1, exact match across all - - if (otherCount === 1) { - const otherKey = otherKeys[0]; - const thisKey = thisKeys[0]; - return ( - thisKey === otherKey && - (!other.callbacks[otherKey] || - !this.callbacks[thisKey] || - other.callbacks[otherKey].matches(this.callbacks[thisKey])) - ); - } else { - // Exact match on each key. - return thisKeys.every(eventType => - other.callbacks[eventType].matches(this.callbacks[eventType]) - ); - } - } - } + return ( + this.eventType === other.eventType && + (!this.callbackContext || + !other.callbackContext || + this.callbackContext.matches(other.callbackContext)) + ); } return false; } hasAnyCallback(): boolean { - return this.callbacks !== null; + return !!this.callbackContext; } } @@ -987,8 +959,8 @@ function addEventListener( if (options && options.onlyOnce) { const userCallback = callback; const onceCallback: UserCallback = (dataSnapshot, previousChildName) => { - userCallback(dataSnapshot, previousChildName); repoRemoveEventCallbackForQuery(query._repo, query, container); + userCallback(dataSnapshot, previousChildName); }; onceCallback.userCallback = callback.userCallback; onceCallback.context = callback.context; @@ -1002,9 +974,7 @@ function addEventListener( const container = eventType === 'value' ? new ValueEventRegistration(callbackContext) - : new ChildEventRegistration({ - [eventType]: callbackContext - }); + : new ChildEventRegistration(eventType, callbackContext); repoAddEventCallbackForQuery(query._repo, query, container); return () => repoRemoveEventCallbackForQuery(query._repo, query, container); } @@ -1656,16 +1626,11 @@ export function off( ) => unknown ): void { let container: EventRegistration | null = null; - let callbacks: { [k: string]: CallbackContext } | null = null; const expCallback = callback ? new CallbackContext(callback) : null; if (eventType === 'value') { container = new ValueEventRegistration(expCallback); } else if (eventType) { - if (callback) { - callbacks = {}; - callbacks[eventType] = expCallback; - } - container = new ChildEventRegistration(callbacks); + container = new ChildEventRegistration(eventType, expCallback); } repoRemoveEventCallbackForQuery(query._repo, query, container); } diff --git a/packages/database/src/realtime/BrowserPollConnection.ts b/packages/database/src/realtime/BrowserPollConnection.ts index 4f60632d1f1..c41a9fb97a1 100644 --- a/packages/database/src/realtime/BrowserPollConnection.ts +++ b/packages/database/src/realtime/BrowserPollConnection.ts @@ -31,6 +31,7 @@ import { } from '../core/util/util'; import { + APP_CHECK_TOKEN_PARAM, APPLICATION_ID_PARAM, FORGE_DOMAIN_RE, FORGE_REF, @@ -99,25 +100,34 @@ export class BrowserPollConnection implements Transport { private onDisconnect_: ((a?: boolean) => void) | null; /** - * @param connId - An identifier for this connection, used for logging - * @param repoInfo - The info for the endpoint to send data to. - * @param applicationId - The Firebase App ID for this project. - * @param transportSessionId - Optional transportSessionid if we are reconnecting for an existing - * transport session - * @param lastSessionId - Optional lastSessionId if the PersistentConnection has already created a - * connection previously + * @param connId An identifier for this connection, used for logging + * @param repoInfo The info for the endpoint to send data to. + * @param applicationId The Firebase App ID for this project. + * @param appCheckToken The AppCheck token for this client. + * @param authToken The AuthToken to use for this connection. + * @param transportSessionId Optional transportSessionid if we are + * reconnecting for an existing transport session + * @param lastSessionId Optional lastSessionId if the PersistentConnection has + * already created a connection previously */ constructor( public connId: string, public repoInfo: RepoInfo, private applicationId?: string, + private appCheckToken?: string, + private authToken?: string, public transportSessionId?: string, public lastSessionId?: string ) { this.log_ = logWrapper(connId); this.stats_ = statsManagerGetCollection(repoInfo); - this.urlFn = (params: { [k: string]: string }) => - repoInfoConnectionURL(repoInfo, LONG_POLLING, params); + this.urlFn = (params: { [k: string]: string }) => { + // Always add the token if we have one. + if (this.appCheckToken) { + params[APP_CHECK_TOKEN_PARAM] = this.appCheckToken; + } + return repoInfoConnectionURL(repoInfo, LONG_POLLING, params); + }; } /** @@ -213,6 +223,9 @@ export class BrowserPollConnection implements Transport { if (this.applicationId) { urlParams[APPLICATION_ID_PARAM] = this.applicationId; } + if (this.appCheckToken) { + urlParams[APP_CHECK_TOKEN_PARAM] = this.appCheckToken; + } if ( typeof location !== 'undefined' && location.hostname && diff --git a/packages/database/src/realtime/Connection.ts b/packages/database/src/realtime/Connection.ts index 6c2ef7967ec..eb52e13e011 100644 --- a/packages/database/src/realtime/Connection.ts +++ b/packages/database/src/realtime/Connection.ts @@ -86,6 +86,8 @@ export class Connection { * @param id - an id for this connection * @param repoInfo_ - the info for the endpoint to connect to * @param applicationId_ - the Firebase App ID for this project + * @param appCheckToken_ - The App Check Token for this device. + * @param authToken_ - The auth token for this session. * @param onMessage_ - the callback to be triggered when a server-push message arrives * @param onReady_ - the callback to be triggered when this connection is ready to send messages. * @param onDisconnect_ - the callback to be triggered when a connection was lost @@ -96,6 +98,8 @@ export class Connection { public id: string, private repoInfo_: RepoInfo, private applicationId_: string | undefined, + private appCheckToken_: string | undefined, + private authToken_: string | undefined, private onMessage_: (a: {}) => void, private onReady_: (a: number, b: string) => void, private onDisconnect_: () => void, @@ -117,7 +121,7 @@ export class Connection { this.nextTransportId_(), this.repoInfo_, this.applicationId_, - undefined, + this.appCheckToken_, this.lastSessionId ); @@ -404,6 +408,8 @@ export class Connection { this.nextTransportId_(), this.repoInfo_, this.applicationId_, + this.appCheckToken_, + this.authToken_, this.sessionId ); // For certain transports (WebSockets), we need to send and receive several messages back and forth before we diff --git a/packages/database/src/realtime/Constants.ts b/packages/database/src/realtime/Constants.ts index 70864addede..4dba53f75dc 100644 --- a/packages/database/src/realtime/Constants.ts +++ b/packages/database/src/realtime/Constants.ts @@ -33,6 +33,8 @@ export const LAST_SESSION_PARAM = 'ls'; export const APPLICATION_ID_PARAM = 'p'; +export const APP_CHECK_TOKEN_PARAM = 'ac'; + export const WEBSOCKET = 'websocket'; export const LONG_POLLING = 'long_polling'; diff --git a/packages/database/src/realtime/Transport.ts b/packages/database/src/realtime/Transport.ts index f3433c4c354..1cb2ff33154 100644 --- a/packages/database/src/realtime/Transport.ts +++ b/packages/database/src/realtime/Transport.ts @@ -22,6 +22,8 @@ export interface TransportConstructor { connId: string, repoInfo: RepoInfo, applicationId?: string, + appCheckToken?: string, + authToken?: string, transportSessionId?: string, lastSessionId?: string ): Transport; diff --git a/packages/database/src/realtime/WebSocketConnection.ts b/packages/database/src/realtime/WebSocketConnection.ts index dad520c3bc0..81c91a95093 100644 --- a/packages/database/src/realtime/WebSocketConnection.ts +++ b/packages/database/src/realtime/WebSocketConnection.ts @@ -25,6 +25,7 @@ import { logWrapper, splitStringBySize } from '../core/util/util'; import { SDK_VERSION } from '../core/version'; import { + APP_CHECK_TOKEN_PARAM, FORGE_DOMAIN_RE, FORGE_REF, LAST_SESSION_PARAM, @@ -73,17 +74,22 @@ export class WebSocketConnection implements Transport { private nodeAdmin: boolean; /** - * @param connId - identifier for this transport - * @param repoInfo - The info for the websocket endpoint. - * @param applicationId - The Firebase App ID for this project. - * @param transportSessionId - Optional transportSessionId if this is connecting to an existing transport - * session - * @param lastSessionId - Optional lastSessionId if there was a previous connection + * @param connId identifier for this transport + * @param repoInfo The info for the websocket endpoint. + * @param applicationId The Firebase App ID for this project. + * @param appCheckToken The App Check Token for this client. + * @param authToken The Auth Token for this client. + * @param transportSessionId Optional transportSessionId if this is connecting + * to an existing transport session + * @param lastSessionId Optional lastSessionId if there was a previous + * connection */ constructor( public connId: string, repoInfo: RepoInfo, private applicationId?: string, + private appCheckToken?: string, + private authToken?: string, transportSessionId?: string, lastSessionId?: string ) { @@ -92,7 +98,8 @@ export class WebSocketConnection implements Transport { this.connURL = WebSocketConnection.connectionURL_( repoInfo, transportSessionId, - lastSessionId + lastSessionId, + appCheckToken ); this.nodeAdmin = repoInfo.nodeAdmin; } @@ -107,7 +114,8 @@ export class WebSocketConnection implements Transport { private static connectionURL_( repoInfo: RepoInfo, transportSessionId?: string, - lastSessionId?: string + lastSessionId?: string, + appCheckToken?: string ): string { const urlParams: { [k: string]: string } = {}; urlParams[VERSION_PARAM] = PROTOCOL_VERSION; @@ -126,6 +134,10 @@ export class WebSocketConnection implements Transport { if (lastSessionId) { urlParams[LAST_SESSION_PARAM] = lastSessionId; } + if (appCheckToken) { + urlParams[APP_CHECK_TOKEN_PARAM] = appCheckToken; + } + return repoInfoConnectionURL(repoInfo, WEBSOCKET, urlParams); } @@ -154,6 +166,19 @@ export class WebSocketConnection implements Transport { } }; + // If using Node with admin creds, AppCheck-related checks are unnecessary. + // It will send the authorization token. + if (this.nodeAdmin) { + options.headers['Authorization'] = this.authToken || ''; + } else { + // If using Node without admin creds (which includes all uses of the + // client-side Node SDK), it will send an AppCheck token if available. + // Any other auth credentials will eventually be sent after the connection + // is established, but aren't needed here as they don't effect the initial + // request to establish a connection. + options.headers['X-Firebase-AppCheck'] = this.appCheckToken || ''; + } + // Plumb appropriate http_proxy environment variable into faye-websocket if it exists. const env = process['env']; const proxy = @@ -169,7 +194,8 @@ export class WebSocketConnection implements Transport { } else { const options: { [k: string]: object } = { headers: { - 'X-Firebase-GMPID': this.applicationId || '' + 'X-Firebase-GMPID': this.applicationId || '', + 'X-Firebase-AppCheck': this.appCheckToken || '' } }; this.mySock = new WebSocketImpl(this.connURL, [], options); diff --git a/packages/database/test/connection.test.ts b/packages/database/test/connection.test.ts index 429740ae8c4..7719b6dfbd4 100644 --- a/packages/database/test/connection.test.ts +++ b/packages/database/test/connection.test.ts @@ -27,6 +27,8 @@ describe('Connection', () => { '1', repoInfoForConnectionTest(), 'fake-app-id', + 'fake-app-check-token', + 'fake-auth-token', message => {}, (timestamp, sessionId) => { expect(sessionId).not.to.be.null; @@ -47,12 +49,16 @@ describe('Connection', () => { '1', info, 'fake-app-id', + 'fake-app-check-token', + 'fake-auth-token', message => {}, (timestamp, sessionId) => { new Connection( '2', info, 'fake-app-id', + 'fake-app-check-token', + 'fake-auth-token', message => {}, (timestamp, sessionId) => {}, () => {}, diff --git a/packages/database/test/helpers/EventAccumulator.ts b/packages/database/test/helpers/EventAccumulator.ts index b9fd2779f4d..798e31405d0 100644 --- a/packages/database/test/helpers/EventAccumulator.ts +++ b/packages/database/test/helpers/EventAccumulator.ts @@ -27,6 +27,23 @@ export const EventAccumulatorFactory = { count++; }); return ea; + }, + waitsForExactCount: maxCount => { + let count = 0; + const condition = () => { + if (count > maxCount) { + throw new Error('Received more events than expected'); + } + return count === maxCount; + }; + const ea = new EventAccumulator(condition); + ea.onReset(() => { + count = 0; + }); + ea.onEvent(() => { + count++; + }); + return ea; } }; diff --git a/packages/database/test/query.test.ts b/packages/database/test/query.test.ts index f8b432a7a47..87f97a65e41 100644 --- a/packages/database/test/query.test.ts +++ b/packages/database/test/query.test.ts @@ -2330,6 +2330,32 @@ describe('Query Tests', () => { await ea.promise; }); + it('Query.once() only fires once', async () => { + const node = getRandomNode() as Reference; + + let count = 1; + node.set(count); + + const valueEvent = EventAccumulatorFactory.waitsForCount(3); + node.on('value', () => { + if (count < 3) { + ++count; + node.set(count); + } + valueEvent.addEvent(); + }); + + const onceEvent = EventAccumulatorFactory.waitsForExactCount(1); + node.once('value', () => { + ++count; + node.set(count); + onceEvent.addEvent(); + }); + + await valueEvent.promise; + await onceEvent.promise; + }); + it('Ensure on() returns callback function.', () => { const node = getRandomNode() as Reference; const callback = function () {}; diff --git a/packages/firebase/CHANGELOG.md b/packages/firebase/CHANGELOG.md index a5457464c5c..7810e572509 100644 --- a/packages/firebase/CHANGELOG.md +++ b/packages/firebase/CHANGELOG.md @@ -1,5 +1,67 @@ # firebase +## 8.6.1 + +### Patch Changes + +- Updated dependencies [[`60e834739`](https://github.com/firebase/firebase-js-sdk/commit/60e83473940e60f8390b1b0f97cf45a1733f66f0), [`5b202f852`](https://github.com/firebase/firebase-js-sdk/commit/5b202f852ca68b35b06b0ea17e4b6b8c446c651c)]: + - @firebase/app@0.6.22 + - @firebase/app-check@0.1.1 + - @firebase/database@0.10.1 + +## 8.6.0 + +### Minor Changes + +- [`81c131abe`](https://github.com/firebase/firebase-js-sdk/commit/81c131abea7001c5933156ff6b0f3925f16ff052) [#4860](https://github.com/firebase/firebase-js-sdk/pull/4860) - Release the Firebase App Check package. + +### Patch Changes + +- [`cc7207e25`](https://github.com/firebase/firebase-js-sdk/commit/cc7207e25f09870c6c718b8e209e694661676d27) [#4870](https://github.com/firebase/firebase-js-sdk/pull/4870) - Fix database.useEmulator typing. + +- Updated dependencies [[`81c131abe`](https://github.com/firebase/firebase-js-sdk/commit/81c131abea7001c5933156ff6b0f3925f16ff052)]: + - @firebase/app-check@0.1.0 + +## 8.5.0 + +### Minor Changes + +- [`97f61e6f3`](https://github.com/firebase/firebase-js-sdk/commit/97f61e6f3d24e5b4c92ed248bb531233a94b9eaf) [#4837](https://github.com/firebase/firebase-js-sdk/pull/4837) (fixes [#4715](https://github.com/firebase/firebase-js-sdk/issues/4715)) - Add mockUserToken support for Firestore. + +* [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467) [#4792](https://github.com/firebase/firebase-js-sdk/pull/4792) - Add mockUserToken support for database emulator. + +### Patch Changes + +- Updated dependencies [[`97f61e6f3`](https://github.com/firebase/firebase-js-sdk/commit/97f61e6f3d24e5b4c92ed248bb531233a94b9eaf), [`e123f241c`](https://github.com/firebase/firebase-js-sdk/commit/e123f241c0cf39a983645582c4e42b7a5bff7bd6), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/firestore@2.3.0 + - @firebase/app@0.6.21 + - @firebase/database@0.10.0 + - @firebase/util@1.1.0 + - @firebase/analytics@0.6.10 + - @firebase/functions@0.6.8 + - @firebase/installations@0.4.26 + - @firebase/messaging@0.7.10 + - @firebase/performance@0.4.12 + - @firebase/remote-config@0.1.37 + - @firebase/storage@0.5.2 + +## 8.4.3 + +### Patch Changes + +- Updated dependencies [[`8d63eacf9`](https://github.com/firebase/firebase-js-sdk/commit/8d63eacf964c6e6b3b8ffe06bf682844ee430fbc), [`d422436d1`](https://github.com/firebase/firebase-js-sdk/commit/d422436d1d83f82aee8028e3a24c8e18d9d7c098)]: + - @firebase/database@0.9.12 + +## 8.4.2 + +### Patch Changes + +- Updated dependencies [[`633463e2a`](https://github.com/firebase/firebase-js-sdk/commit/633463e2abfdef7dbb6d9bf5275df21d6a01fcb6), [`c65883680`](https://github.com/firebase/firebase-js-sdk/commit/c658836806e0a5fef11fa61cd68f98960567f31b), [`364e336a0`](https://github.com/firebase/firebase-js-sdk/commit/364e336a04e419d019846d702cf27144aeb8939e), [`191184eb4`](https://github.com/firebase/firebase-js-sdk/commit/191184eb454109bff9198274fc416664b126d7ec)]: + - @firebase/firestore@2.2.5 + - @firebase/storage@0.5.1 + - @firebase/database@0.9.11 + - @firebase/auth@0.16.5 + ## 8.4.1 ### Patch Changes diff --git a/packages/firebase/app-check/index.ts b/packages/firebase/app-check/index.ts new file mode 100644 index 00000000000..78ceba036c3 --- /dev/null +++ b/packages/firebase/app-check/index.ts @@ -0,0 +1,18 @@ +/** + * @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 '@firebase/app-check'; diff --git a/packages/firebase/app-check/package.json b/packages/firebase/app-check/package.json new file mode 100644 index 00000000000..2d7360d675a --- /dev/null +++ b/packages/firebase/app-check/package.json @@ -0,0 +1,6 @@ +{ + "name": "firebase/app-check", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "typings": "../empty-import.d.ts" +} diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index d1c5ef64870..d22d2f97716 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -1006,6 +1006,81 @@ declare namespace firebase { uid: string; } + type FirebaseSignInProvider = + | 'custom' + | 'email' + | 'password' + | 'phone' + | 'anonymous' + | 'google.com' + | 'facebook.com' + | 'github.com' + | 'twitter.com' + | 'microsoft.com' + | 'apple.com'; + + interface FirebaseIdToken { + /** Always set to https://securetoken.google.com/PROJECT_ID */ + iss: string; + + /** Always set to PROJECT_ID */ + aud: string; + + /** The user's unique id */ + sub: string; + + /** The token issue time, in seconds since epoch */ + iat: number; + + /** The token expiry time, normally 'iat' + 3600 */ + exp: number; + + /** The user's unique id, must be equal to 'sub' */ + user_id: string; + + /** The time the user authenticated, normally 'iat' */ + auth_time: number; + + /** The sign in provider, only set when the provider is 'anonymous' */ + provider_id?: 'anonymous'; + + /** The user's primary email */ + email?: string; + + /** The user's email verification status */ + email_verified?: boolean; + + /** The user's primary phone number */ + phone_number?: string; + + /** The user's display name */ + name?: string; + + /** The user's profile photo URL */ + picture?: string; + + /** Information on all identities linked to this user */ + firebase: { + /** The primary sign-in provider */ + sign_in_provider: FirebaseSignInProvider; + + /** A map of providers to the user's list of unique identifiers from each provider */ + identities?: { [provider in FirebaseSignInProvider]?: string[] }; + }; + + /** Custom claims set by the developer */ + [claim: string]: unknown; + + // NO LONGER SUPPORTED. Use "sub" instead. (Not a jsdoc comment to avoid generating docs.) + uid?: never; + } + + export type EmulatorMockTokenOptions = ( + | { user_id: string } + | { sub: string } + ) & + Partial; + /** * Retrieves a Firebase {@link firebase.app.App app} instance. * @@ -1274,6 +1349,8 @@ declare namespace firebase { * If not passed, uses the default app. */ function analytics(app?: firebase.app.App): firebase.analytics.Analytics; + + function appCheck(app?: firebase.app.App): firebase.appCheck.AppCheck; } declare namespace firebase.app { @@ -1450,6 +1527,68 @@ declare namespace firebase.app { * ``` */ analytics(): firebase.analytics.Analytics; + appCheck(): firebase.appCheck.AppCheck; + } +} + +/** + * @webonly + */ +declare namespace firebase.appCheck { + /** + * The Firebase AppCheck service interface. + * + * Do not call this constructor directly. Instead, use + * {@link firebase.appCheck `firebase.appCheck()`}. + */ + export interface AppCheck { + /** + * Activate AppCheck + * @param siteKeyOrProvider reCAPTCHA v3 site key (public key) or + * custom token provider. + * @param isTokenAutoRefreshEnabled If true, the SDK automatically + * refreshes App Check tokens as needed. If undefined, defaults to the + * value of `app.automaticDataCollectionEnabled`, which defaults to + * false and can be set in the app config. + */ + activate( + siteKeyOrProvider: string | AppCheckProvider, + isTokenAutoRefreshEnabled?: boolean + ): void; + + /** + * + * @param isTokenAutoRefreshEnabled If true, the SDK automatically + * refreshes App Check tokens as needed. This overrides any value set + * during `activate()`. + */ + setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled: boolean): void; + } + + /** + * An App Check provider. This can be either the built-in reCAPTCHA + * provider or a custom provider. For more on custom providers, see + * https://firebase.google.com/docs/app-check/web-custom-provider + */ + interface AppCheckProvider { + /** + * Returns an AppCheck token. + */ + getToken(): Promise; + } + + /** + * The token returned from an {@link firebase.appCheck.AppCheckProvider `AppCheckProvider`}. + */ + interface AppCheckToken { + /** + * The token string in JWT format. + */ + readonly token: string; + /** + * The local timestamp after which the token will expire. + */ + readonly expireTimeMillis: number; } } @@ -5674,8 +5813,15 @@ declare namespace firebase.database { * * @param host the emulator host (ex: localhost) * @param port the emulator port (ex: 8080) + * @param options.mockUserToken the mock auth token to use for unit testing Security Rules */ - useEmulator(host: string, port: number): void; + useEmulator( + host: string, + port: number, + options?: { + mockUserToken?: EmulatorMockTokenOptions; + } + ): void; /** * Disconnects from the server (all Database operations will be completed * offline). @@ -7017,6 +7163,8 @@ declare namespace firebase.database { logger?: boolean | ((a: string) => any), persistent?: boolean ): any; + + export type EmulatorMockTokenOptions = firebase.EmulatorMockTokenOptions; } declare namespace firebase.database.ServerValue { @@ -8142,8 +8290,16 @@ declare namespace firebase.firestore { * * @param host the emulator host (ex: localhost). * @param port the emulator port (ex: 9000). + * @param options.mockUserToken - the mock auth token to use for unit + * testing Security Rules. */ - useEmulator(host: string, port: number): void; + useEmulator( + host: string, + port: number, + options?: { + mockUserToken?: EmulatorMockTokenOptions; + } + ): void; /** * Attempts to enable persistent storage, if possible. @@ -9939,6 +10095,8 @@ declare namespace firebase.firestore { name: string; stack?: string; } + + export type EmulatorMockTokenOptions = firebase.EmulatorMockTokenOptions; } export default firebase; diff --git a/packages/firebase/package.json b/packages/firebase/package.json index 9d9c550ba73..a39408d935e 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -1,6 +1,6 @@ { "name": "firebase", - "version": "8.4.1", + "version": "8.6.1", "description": "Firebase JavaScript library for web and Node.js", "author": "Firebase (https://firebase.google.com/)", "license": "Apache-2.0", @@ -44,20 +44,21 @@ "module": "dist/index.esm.js", "react-native": "dist/index.rn.cjs.js", "dependencies": { - "@firebase/app": "0.6.20", + "@firebase/app": "0.6.22", "@firebase/app-types": "0.6.2", - "@firebase/auth": "0.16.4", - "@firebase/database": "0.9.10", - "@firebase/firestore": "2.2.4", - "@firebase/functions": "0.6.7", - "@firebase/installations": "0.4.25", - "@firebase/messaging": "0.7.9", + "@firebase/auth": "0.16.5", + "@firebase/database": "0.10.1", + "@firebase/firestore": "2.3.0", + "@firebase/functions": "0.6.8", + "@firebase/installations": "0.4.26", + "@firebase/messaging": "0.7.10", "@firebase/polyfill": "0.3.36", - "@firebase/storage": "0.5.0", - "@firebase/performance": "0.4.11", - "@firebase/remote-config": "0.1.36", - "@firebase/analytics": "0.6.9", - "@firebase/util": "1.0.0" + "@firebase/storage": "0.5.2", + "@firebase/performance": "0.4.12", + "@firebase/remote-config": "0.1.37", + "@firebase/analytics": "0.6.10", + "@firebase/app-check": "0.1.1", + "@firebase/util": "1.1.0" }, "devDependencies": { "rollup": "2.35.1", @@ -74,6 +75,7 @@ "components": [ "analytics", "app", + "app-check", "auth", "database", "firestore", diff --git a/packages/firebase/src/index.cdn.ts b/packages/firebase/src/index.cdn.ts index 6b0ff9733fa..c7a66db422a 100644 --- a/packages/firebase/src/index.cdn.ts +++ b/packages/firebase/src/index.cdn.ts @@ -40,6 +40,7 @@ import '../storage'; import '../performance'; import '../analytics'; import '../remote-config'; +import '../app-check'; firebase.registerVersion(name, version, 'cdn'); diff --git a/packages/firebase/src/index.ts b/packages/firebase/src/index.ts index 18d832dfc60..d518658721e 100644 --- a/packages/firebase/src/index.ts +++ b/packages/firebase/src/index.ts @@ -49,6 +49,7 @@ import '../storage'; import '../performance'; import '../analytics'; import '../remote-config'; +import '../app-check'; firebase.registerVersion(name, version); diff --git a/packages/firestore-types/CHANGELOG.md b/packages/firestore-types/CHANGELOG.md index e271be3c950..30e66507f78 100644 --- a/packages/firestore-types/CHANGELOG.md +++ b/packages/firestore-types/CHANGELOG.md @@ -1,5 +1,11 @@ # @firebase/firestore-types +## 2.3.0 + +### Minor Changes + +- [`97f61e6f3`](https://github.com/firebase/firebase-js-sdk/commit/97f61e6f3d24e5b4c92ed248bb531233a94b9eaf) [#4837](https://github.com/firebase/firebase-js-sdk/pull/4837) (fixes [#4715](https://github.com/firebase/firebase-js-sdk/issues/4715)) - Add mockUserToken support for Firestore. + ## 2.2.0 ### Minor Changes diff --git a/packages/firestore-types/index.d.ts b/packages/firestore-types/index.d.ts index 13232f20a53..f7a76e32064 100644 --- a/packages/firestore-types/index.d.ts +++ b/packages/firestore-types/index.d.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { FirebaseApp, FirebaseNamespace } from '@firebase/app-types'; +import { EmulatorMockTokenOptions } from '@firebase/util'; export type DocumentData = { [field: string]: any }; @@ -61,7 +61,13 @@ export class FirebaseFirestore { settings(settings: Settings): void; - useEmulator(host: string, port: number): void; + useEmulator( + host: string, + port: number, + options?: { + mockUserToken?: EmulatorMockTokenOptions; + } + ): void; enablePersistence(settings?: PersistenceSettings): Promise; diff --git a/packages/firestore-types/package.json b/packages/firestore-types/package.json index b73bcf87f05..3d16ad8a635 100644 --- a/packages/firestore-types/package.json +++ b/packages/firestore-types/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/firestore-types", - "version": "2.2.0", + "version": "2.3.0", "description": "@firebase/firestore Types", "author": "Firebase (https://firebase.google.com/)", "license": "Apache-2.0", @@ -12,7 +12,8 @@ "index.d.ts" ], "peerDependencies": { - "@firebase/app-types": "0.x" + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" }, "repository": { "directory": "packages/firestore-types", diff --git a/packages/firestore/.idea/runConfigurations/All_Tests__Emulator_.xml b/packages/firestore/.idea/runConfigurations/All_Tests__Emulator_.xml index eb6ce40bb06..92d7cc57670 100644 --- a/packages/firestore/.idea/runConfigurations/All_Tests__Emulator_.xml +++ b/packages/firestore/.idea/runConfigurations/All_Tests__Emulator_.xml @@ -13,7 +13,7 @@ bdd --require ts-node/register/type-check --require index.node.ts --timeout 5000 PATTERN - test/{,!(browser)/**/}*.test.ts + test/{,!(browser|lite)/**/}*.test.ts \ No newline at end of file diff --git a/packages/firestore/.idea/runConfigurations/All_Tests__Emulator_w__Mock_Persistence_.xml b/packages/firestore/.idea/runConfigurations/All_Tests__Emulator_w__Mock_Persistence_.xml index 4e37c1bea2b..c2181e91517 100644 --- a/packages/firestore/.idea/runConfigurations/All_Tests__Emulator_w__Mock_Persistence_.xml +++ b/packages/firestore/.idea/runConfigurations/All_Tests__Emulator_w__Mock_Persistence_.xml @@ -14,7 +14,7 @@ bdd --require ts-node/register/type-check --require index.node.ts --require test/util/node_persistence.ts --timeout 5000 PATTERN - test/{,!(browser)/**/}*.test.ts + test/{,!(browser|lite)/**/}*.test.ts \ No newline at end of file diff --git a/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_.xml b/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_.xml index be91ad5e0d3..1ea460ca6e5 100644 --- a/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_.xml +++ b/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_.xml @@ -13,7 +13,7 @@ bdd --require ts-node/register/type-check --require index.node.ts --timeout 5000 PATTERN - test/integration/{,!(browser)/**/}*.test.ts + test/integration/{,!(browser|lite)/**/}*.test.ts \ No newline at end of file diff --git a/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_w__Mock_Persistence_.xml b/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_w__Mock_Persistence_.xml index 4d0ea9ef9b0..6d9215345e1 100644 --- a/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_w__Mock_Persistence_.xml +++ b/packages/firestore/.idea/runConfigurations/Integration_Tests__Emulator_w__Mock_Persistence_.xml @@ -14,7 +14,7 @@ bdd --require ts-node/register/type-check --require index.node.ts --require test/util/node_persistence.ts --timeout 5000 PATTERN - test/integration/{,!(browser)/**/}*.test.ts + test/integration/{,!(browser|lite)/**/}*.test.ts \ No newline at end of file diff --git a/packages/firestore/.idea/runConfigurations/Unit_Tests.xml b/packages/firestore/.idea/runConfigurations/Unit_Tests.xml index a3388476f6c..5d0dcda754a 100644 --- a/packages/firestore/.idea/runConfigurations/Unit_Tests.xml +++ b/packages/firestore/.idea/runConfigurations/Unit_Tests.xml @@ -11,7 +11,7 @@ bdd --require ts-node/register/type-check --require index.node.ts PATTERN - test/unit/{,!(browser)/**/}*.test.ts + test/unit/{,!(browser|lite)/**/}*.test.ts - + \ No newline at end of file diff --git a/packages/firestore/.idea/runConfigurations/Unit_Tests__w__Mock_Persistence_.xml b/packages/firestore/.idea/runConfigurations/Unit_Tests__w__Mock_Persistence_.xml index de117facf4b..f62d34dcd93 100644 --- a/packages/firestore/.idea/runConfigurations/Unit_Tests__w__Mock_Persistence_.xml +++ b/packages/firestore/.idea/runConfigurations/Unit_Tests__w__Mock_Persistence_.xml @@ -12,7 +12,7 @@ bdd --require ts-node/register/type-check --require index.node.ts --require test/util/node_persistence.ts PATTERN - test/unit/{,!(browser)/**/}*.test.ts + test/unit/{,!(browser|lite)/**/}*.test.ts \ No newline at end of file diff --git a/packages/firestore/CHANGELOG.md b/packages/firestore/CHANGELOG.md index 119c173b8eb..8d4de9f598c 100644 --- a/packages/firestore/CHANGELOG.md +++ b/packages/firestore/CHANGELOG.md @@ -1,5 +1,26 @@ # @firebase/firestore +## 2.3.0 + +### Minor Changes + +- [`97f61e6f3`](https://github.com/firebase/firebase-js-sdk/commit/97f61e6f3d24e5b4c92ed248bb531233a94b9eaf) [#4837](https://github.com/firebase/firebase-js-sdk/pull/4837) (fixes [#4715](https://github.com/firebase/firebase-js-sdk/issues/4715)) - Add mockUserToken support for Firestore. + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`97f61e6f3`](https://github.com/firebase/firebase-js-sdk/commit/97f61e6f3d24e5b4c92ed248bb531233a94b9eaf), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/firestore-types@2.3.0 + - @firebase/util@1.1.0 + +## 2.2.5 + +### Patch Changes + +- [`633463e2a`](https://github.com/firebase/firebase-js-sdk/commit/633463e2abfdef7dbb6d9bf5275df21d6a01fcb6) [#4788](https://github.com/firebase/firebase-js-sdk/pull/4788) - Ensure that errors get wrapped in FirestoreError + +* [`c65883680`](https://github.com/firebase/firebase-js-sdk/commit/c658836806e0a5fef11fa61cd68f98960567f31b) [#4810](https://github.com/firebase/firebase-js-sdk/pull/4810) (fixes [#4795](https://github.com/firebase/firebase-js-sdk/issues/4795)) - Don't send empty X-Firebase-GMPID header when AppId is not set in FirebaseOptions + ## 2.2.4 ### Patch Changes diff --git a/packages/firestore/compat/index.node.ts b/packages/firestore/compat/index.node.ts index 6370fd30631..21ecd90dbf1 100644 --- a/packages/firestore/compat/index.node.ts +++ b/packages/firestore/compat/index.node.ts @@ -20,6 +20,7 @@ import firebase from '@firebase/app-compat'; import { FirebaseNamespace } from '@firebase/app-types'; import { Firestore, IndexedDbPersistenceProvider } from '../src/api/database'; +import { setSDKVersion } from '../src/core/version'; import { registerBundle } from './bundle'; import { configureForFirebase } from './config'; @@ -30,6 +31,7 @@ import { name, version } from './package.json'; * Persistence can be enabled via `firebase.firestore().enablePersistence()`. */ export function registerFirestore(instance: FirebaseNamespace): void { + setSDKVersion(instance.SDK_VERSION); configureForFirebase( instance, (app, firestoreExp) => diff --git a/packages/firestore/compat/index.rn.ts b/packages/firestore/compat/index.rn.ts index a07370c6adb..32f2e0dab4a 100644 --- a/packages/firestore/compat/index.rn.ts +++ b/packages/firestore/compat/index.rn.ts @@ -20,16 +20,17 @@ import firebase from '@firebase/app-compat'; import { FirebaseNamespace } from '@firebase/app-types'; import { Firestore, IndexedDbPersistenceProvider } from '../src/api/database'; +import { setSDKVersion } from '../src/core/version'; import { registerBundle } from './bundle'; import { configureForFirebase } from './config'; import { name, version } from './package.json'; - /** * Registers the main Firestore ReactNative build with the components framework. * Persistence can be enabled via `firebase.firestore().enablePersistence()`. */ export function registerFirestore(instance: FirebaseNamespace): void { + setSDKVersion(instance.SDK_VERSION); configureForFirebase( instance, (app, firestoreExp) => diff --git a/packages/firestore/compat/index.ts b/packages/firestore/compat/index.ts index e35b68d823b..9084fe34150 100644 --- a/packages/firestore/compat/index.ts +++ b/packages/firestore/compat/index.ts @@ -21,6 +21,7 @@ import { FirebaseNamespace } from '@firebase/app-types'; import * as types from '@firebase/firestore-types'; import { Firestore, IndexedDbPersistenceProvider } from '../src/api/database'; +import { setSDKVersion } from '../src/core/version'; import { registerBundle } from './bundle'; import { configureForFirebase } from './config'; @@ -33,6 +34,7 @@ import '../register-module'; * Persistence can be enabled via `firebase.firestore().enablePersistence()`. */ export function registerFirestore(instance: FirebaseNamespace): void { + setSDKVersion(instance.SDK_VERSION); configureForFirebase( instance, (app, firestoreExp) => diff --git a/packages/firestore/exp/package.json b/packages/firestore/exp/package.json index 9d28e74e60d..375bed2ae15 100644 --- a/packages/firestore/exp/package.json +++ b/packages/firestore/exp/package.json @@ -1,11 +1,12 @@ { "name": "@firebase/firestore-exp", "description": "A tree-shakeable version of the Firestore SDK", - "main": "../dist/exp/index.node.umd.js", + "main": "../dist/exp/index.node.cjs.js", "main-esm": "../dist/exp/index.node.esm2017.js", "module": "../dist/exp/index.browser.esm2017.js", "browser": "../dist/exp/index.browser.esm2017.js", "react-native": "../dist/exp/index.rn.esm2017.js", + "esm5": "../dist/exp/index.browser.esm5.js", "typings": "../dist/exp/index.d.ts", "private": true } diff --git a/packages/firestore/exp/register.ts b/packages/firestore/exp/register.ts index 740f8d2e3c1..20f54426322 100644 --- a/packages/firestore/exp/register.ts +++ b/packages/firestore/exp/register.ts @@ -15,10 +15,15 @@ * limitations under the License. */ -import { _registerComponent, registerVersion } from '@firebase/app-exp'; +import { + _registerComponent, + registerVersion, + SDK_VERSION +} from '@firebase/app-exp'; import { Component, ComponentType } from '@firebase/component'; import { name, version } from '../package.json'; +import { setSDKVersion } from '../src/core/version'; import { FirebaseFirestore } from '../src/exp/database'; import { Settings } from '../src/exp/settings'; @@ -29,6 +34,7 @@ declare module '@firebase/component' { } export function registerFirestore(variant?: string): void { + setSDKVersion(SDK_VERSION); _registerComponent( new Component( 'firestore-exp', diff --git a/packages/firestore/externs.json b/packages/firestore/externs.json index de3847a83f9..5b9cb8cda84 100644 --- a/packages/firestore/externs.json +++ b/packages/firestore/externs.json @@ -1,5 +1,5 @@ { - "externs" : [ + "externs": [ "node_modules/@types/node/base.d.ts", "node_modules/@types/node/globals.d.ts", "node_modules/typescript/lib/lib.es5.d.ts", @@ -26,6 +26,7 @@ "packages/logger/dist/src/logger.d.ts", "packages/webchannel-wrapper/src/index.d.ts", "packages/util/dist/src/crypt.d.ts", + "packages/util/dist/src/emulator.d.ts", "packages/util/dist/src/environment.d.ts", "packages/util/dist/src/compat.d.ts", "packages/firestore/export.ts", diff --git a/packages/firestore/lite/index.ts b/packages/firestore/lite/index.ts index bccc14df79d..12e1696c713 100644 --- a/packages/firestore/lite/index.ts +++ b/packages/firestore/lite/index.ts @@ -1,3 +1,12 @@ +/** + * Firestore Lite + * + * @remarks Firestore Lite is a small online-only SDK that allows read + * and write access to your Firestore database. All operations connect + * directly to the backend, and `onSnapshot()` APIs are not supported. + * @packageDocumentation + */ + /** * @license * Copyright 2020 Google LLC diff --git a/packages/firestore/lite/package.json b/packages/firestore/lite/package.json index d00ccc41f37..662f61080cc 100644 --- a/packages/firestore/lite/package.json +++ b/packages/firestore/lite/package.json @@ -1,11 +1,12 @@ { "name": "@firebase/firestore-lite", "description": "A lite version of the Firestore SDK", - "main": "../dist/lite/index.node.umd.js", + "main": "../dist/lite/index.node.cjs.js", "main-esm": "../dist/lite/index.node.esm2017.js", "module": "../dist/lite/index.browser.esm2017.js", "browser": "../dist/lite/index.browser.esm2017.js", "react-native": "../dist/lite/index.rn.esm2017.js", + "esm5": "../dist/lite/index.browser.esm5.js", "typings": "../dist/lite/index.d.ts", "private": true } diff --git a/packages/firestore/lite/register.ts b/packages/firestore/lite/register.ts index cb17b6a25fe..e753a3d216b 100644 --- a/packages/firestore/lite/register.ts +++ b/packages/firestore/lite/register.ts @@ -15,10 +15,15 @@ * limitations under the License. */ -import { _registerComponent, registerVersion } from '@firebase/app-exp'; +import { + _registerComponent, + registerVersion, + SDK_VERSION +} from '@firebase/app-exp'; import { Component, ComponentType } from '@firebase/component'; import { version } from '../package.json'; +import { setSDKVersion } from '../src/core/version'; import { FirebaseFirestore } from '../src/lite/database'; import { Settings } from '../src/lite/settings'; @@ -29,6 +34,7 @@ declare module '@firebase/component' { } export function registerFirestore(): void { + setSDKVersion(`${SDK_VERSION}_lite`); _registerComponent( new Component( 'firestore/lite', diff --git a/packages/firestore/package.json b/packages/firestore/package.json index fb250bb8897..80513d53772 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/firestore", - "version": "2.2.4", + "version": "2.3.0", "engines": { "node": "^8.13.0 || >=10.10.0" }, @@ -66,10 +66,10 @@ "memory-bundle/package.json" ], "dependencies": { - "@firebase/component": "0.4.1", - "@firebase/firestore-types": "2.2.0", + "@firebase/component": "0.5.0", + "@firebase/firestore-types": "2.3.0", "@firebase/logger": "0.2.6", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "@firebase/webchannel-wrapper": "0.4.1", "@grpc/grpc-js": "^1.0.0", "@grpc/proto-loader": "^0.5.0", @@ -81,7 +81,7 @@ "@firebase/app-types": "0.x" }, "devDependencies": { - "@firebase/app": "0.6.20", + "@firebase/app": "0.6.22", "@rollup/plugin-alias": "3.1.1", "@rollup/plugin-json": "4.1.0", "@rollup/plugin-node-resolve": "11.2.0", diff --git a/packages/firestore/rollup.config.exp.js b/packages/firestore/rollup.config.exp.js index 9e4f55dc937..1367380c3e7 100644 --- a/packages/firestore/rollup.config.exp.js +++ b/packages/firestore/rollup.config.exp.js @@ -97,13 +97,12 @@ const allBuilds = [ }, onwarn: util.onwarn }, - // Node UMD build + // Node CJS build { input: path.resolve('./exp', pkg['main-esm']), output: { file: path.resolve('./exp', pkg.main), - format: 'umd', - name: 'firebase.firestore', + format: 'cjs', sourcemap: true }, plugins: util.es2017ToEs5Plugins(/* mangled= */ false), @@ -126,6 +125,22 @@ const allBuilds = [ moduleSideEffects: false } }, + // Convert es2017 build to ES5 + { + input: path.resolve('./exp', pkg['browser']), + output: [ + { + file: path.resolve('./exp', pkg['esm5']), + format: 'es', + sourcemap: true + } + ], + plugins: util.es2017ToEs5Plugins(/* mangled= */ true), + external: util.resolveBrowserExterns, + treeshake: { + moduleSideEffects: false + } + }, // RN build { input: './exp/index.rn.ts', diff --git a/packages/firestore/rollup.config.lite.js b/packages/firestore/rollup.config.lite.js index 16a57484129..8fb916fca3f 100644 --- a/packages/firestore/rollup.config.lite.js +++ b/packages/firestore/rollup.config.lite.js @@ -96,13 +96,12 @@ const allBuilds = [ }, onwarn: util.onwarn }, - // Node UMD build + // Node CJS build { input: path.resolve('./lite', pkg['main-esm']), output: { file: path.resolve('./lite', pkg.main), - format: 'umd', - name: 'firebase.firestore', + format: 'cjs', sourcemap: true }, plugins: [ @@ -139,6 +138,22 @@ const allBuilds = [ moduleSideEffects: false } }, + // Convert es2017 build to ES5 + { + input: path.resolve('./lite', pkg.browser), + output: [ + { + file: path.resolve('./lite', pkg.esm5), + format: 'es', + sourcemap: true + } + ], + plugins: util.es2017ToEs5Plugins(/* mangled= */ true), + external: util.resolveBrowserExterns, + treeshake: { + moduleSideEffects: false + } + }, // RN build { input: './lite/index.ts', diff --git a/packages/firestore/src/api/credentials.ts b/packages/firestore/src/api/credentials.ts index ca1b0006ba2..566e393b274 100644 --- a/packages/firestore/src/api/credentials.ts +++ b/packages/firestore/src/api/credentials.ts @@ -23,8 +23,10 @@ import { Provider } from '@firebase/component'; import { User } from '../auth/user'; import { hardAssert, debugAssert } from '../util/assert'; +import { AsyncQueue } from '../util/async_queue'; import { Code, FirestoreError } from '../util/error'; import { logDebug } from '../util/log'; +import { Deferred } from '../util/promise'; // TODO(mikelehen): This should be split into multiple files and probably // moved to an auth/ folder to match other platforms. @@ -78,7 +80,7 @@ export class OAuthToken implements Token { * token and may need to invalidate other state if the current user has also * changed. */ -export type CredentialChangeListener = (user: User) => void; +export type CredentialChangeListener = (user: User) => Promise; /** * Provides methods for getting the uid and token for the current user and @@ -98,8 +100,13 @@ export interface CredentialsProvider { * Specifies a listener to be notified of credential changes * (sign-in / sign-out, token changes). It is immediately called once with the * initial user. + * + * The change listener is invoked on the provided AsyncQueue. */ - setChangeListener(changeListener: CredentialChangeListener): void; + setChangeListener( + asyncQueue: AsyncQueue, + changeListener: CredentialChangeListener + ): void; /** Removes the previously-set change listener. */ removeChangeListener(): void; @@ -120,14 +127,55 @@ export class EmptyCredentialsProvider implements CredentialsProvider { invalidateToken(): void {} - setChangeListener(changeListener: CredentialChangeListener): void { + setChangeListener( + asyncQueue: AsyncQueue, + changeListener: CredentialChangeListener + ): void { debugAssert( !this.changeListener, 'Can only call setChangeListener() once.' ); this.changeListener = changeListener; // Fire with initial user. - changeListener(User.UNAUTHENTICATED); + asyncQueue.enqueueRetryable(() => changeListener(User.UNAUTHENTICATED)); + } + + removeChangeListener(): void { + this.changeListener = null; + } +} + +/** + * A CredentialsProvider that always returns a constant token. Used for + * emulator token mocking. + */ +export class EmulatorCredentialsProvider implements CredentialsProvider { + constructor(private token: Token) {} + + /** + * Stores the listener registered with setChangeListener() + * This isn't actually necessary since the UID never changes, but we use this + * to verify the listen contract is adhered to in tests. + */ + private changeListener: CredentialChangeListener | null = null; + + getToken(): Promise { + return Promise.resolve(this.token); + } + + invalidateToken(): void {} + + setChangeListener( + asyncQueue: AsyncQueue, + changeListener: CredentialChangeListener + ): void { + debugAssert( + !this.changeListener, + 'Can only call setChangeListener() once.' + ); + this.changeListener = changeListener; + // Fire with initial user. + asyncQueue.enqueueRetryable(() => changeListener(this.token.user)); } removeChangeListener(): void { @@ -140,11 +188,13 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { * The auth token listener registered with FirebaseApp, retained here so we * can unregister it. */ - private tokenListener: ((token: string | null) => void) | null = null; + private tokenListener: () => void; /** Tracks the current User. */ private currentUser: User = User.UNAUTHENTICATED; - private receivedInitialUser: boolean = false; + + /** Promise that allows blocking on the first `tokenListener` event. */ + private receivedInitialUser = new Deferred(); /** * Counter used to detect if the token changed while a getToken request was @@ -153,44 +203,55 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { private tokenCounter = 0; /** The listener registered with setChangeListener(). */ - private changeListener: CredentialChangeListener | null = null; + private changeListener: CredentialChangeListener = () => Promise.resolve(); + + private invokeChangeListener = false; private forceRefresh = false; - private auth: FirebaseAuthInternal | null; + private auth: FirebaseAuthInternal | null = null; + + private asyncQueue: AsyncQueue | null = null; constructor(authProvider: Provider) { this.tokenListener = () => { this.tokenCounter++; this.currentUser = this.getUser(); - this.receivedInitialUser = true; - if (this.changeListener) { - this.changeListener(this.currentUser); + this.receivedInitialUser.resolve(); + if (this.invokeChangeListener) { + this.asyncQueue!.enqueueRetryable(() => + this.changeListener(this.currentUser) + ); } }; - this.tokenCounter = 0; - - this.auth = authProvider.getImmediate({ optional: true }); + const registerAuth = (auth: FirebaseAuthInternal): void => { + logDebug('FirebaseCredentialsProvider', 'Auth detected'); + this.auth = auth; + this.awaitTokenAndRaiseInitialEvent(); + this.auth.addAuthTokenListener(this.tokenListener); + }; - if (this.auth) { - this.auth.addAuthTokenListener(this.tokenListener!); - } else { - // if auth is not available, invoke tokenListener once with null token - this.tokenListener(null); - authProvider.get().then( - auth => { - this.auth = auth; - if (this.tokenListener) { - // tokenListener can be removed by removeChangeListener() - this.auth.addAuthTokenListener(this.tokenListener); - } - }, - () => { - /* this.authProvider.get() never rejects */ + authProvider.onInit(auth => registerAuth(auth)); + + // Our users can initialize Auth right after Firestore, so we give it + // a chance to register itself with the component framework before we + // determine whether to start up in unauthenticated mode. + setTimeout(() => { + if (!this.auth) { + const auth = authProvider.getImmediate({ optional: true }); + if (auth) { + registerAuth(auth); + } else if (this.invokeChangeListener) { + // If auth is still not available, invoke the change listener once + // with null token + logDebug('FirebaseCredentialsProvider', 'Auth not yet detected'); + this.asyncQueue!.enqueueRetryable(() => + this.changeListener(this.currentUser) + ); } - ); - } + } + }, 0); } getToken(): Promise { @@ -238,25 +299,21 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { this.forceRefresh = true; } - setChangeListener(changeListener: CredentialChangeListener): void { - debugAssert( - !this.changeListener, - 'Can only call setChangeListener() once.' - ); + setChangeListener( + asyncQueue: AsyncQueue, + changeListener: CredentialChangeListener + ): void { + debugAssert(!this.asyncQueue, 'Can only call setChangeListener() once.'); + this.invokeChangeListener = true; + this.asyncQueue = asyncQueue; this.changeListener = changeListener; - - // Fire the initial event - if (this.receivedInitialUser) { - changeListener(this.currentUser); - } } removeChangeListener(): void { if (this.auth) { this.auth.removeAuthTokenListener(this.tokenListener!); } - this.tokenListener = null; - this.changeListener = null; + this.changeListener = () => Promise.resolve(); } // Auth.getUid() can return null even with a user logged in. It is because @@ -271,6 +328,21 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { ); return new User(currentUid); } + + /** + * Blocks the AsyncQueue until the next user is available. This function also + * invokes `this.changeListener` immediately once the token is available. + */ + private awaitTokenAndRaiseInitialEvent(): void { + if (this.invokeChangeListener) { + this.invokeChangeListener = false; // Prevent double-firing of the listener + this.asyncQueue!.enqueueRetryable(async () => { + await this.receivedInitialUser.promise; + await this.changeListener(this.currentUser); + this.invokeChangeListener = true; + }); + } + } } // Manual type definition for the subset of Gapi we use. @@ -333,9 +405,12 @@ export class FirstPartyCredentialsProvider implements CredentialsProvider { ); } - setChangeListener(changeListener: CredentialChangeListener): void { + setChangeListener( + asyncQueue: AsyncQueue, + changeListener: CredentialChangeListener + ): void { // Fire with initial uid. - changeListener(User.FIRST_PARTY); + asyncQueue.enqueueRetryable(() => changeListener(User.FIRST_PARTY)); } removeChangeListener(): void {} diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index d3002c2ad51..9b389d51bed 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -44,7 +44,11 @@ import { WhereFilterOp as PublicWhereFilterOp, WriteBatch as PublicWriteBatch } from '@firebase/firestore-types'; -import { Compat, getModularInstance } from '@firebase/util'; +import { + Compat, + EmulatorMockTokenOptions, + getModularInstance +} from '@firebase/util'; import { LoadBundleTask, @@ -223,8 +227,14 @@ export class Firestore this._delegate._setSettings(settingsLiteral); } - useEmulator(host: string, port: number): void { - useFirestoreEmulator(this._delegate, host, port); + useEmulator( + host: string, + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions; + } = {} + ): void { + useFirestoreEmulator(this._delegate, host, port, options); } enableNetwork(): Promise { @@ -409,7 +419,7 @@ export class Transaction implements PublicTransaction, Compat { result._key, result._document, result.metadata, - ref._converter + ref.converter ) ) ); @@ -775,7 +785,7 @@ export class DocumentReference result._key, result._document, result.metadata, - this._delegate._converter + this._delegate.converter ) ) ); @@ -802,7 +812,7 @@ export class DocumentReference result._key, result._document, result.metadata, - this._delegate._converter as UntypedFirestoreDataConverter + this._delegate.converter as UntypedFirestoreDataConverter ) ) ); diff --git a/packages/firestore/src/core/firestore_client.ts b/packages/firestore/src/core/firestore_client.ts index f80b093bce0..575c2ceb5a0 100644 --- a/packages/firestore/src/core/firestore_client.ts +++ b/packages/firestore/src/core/firestore_client.ts @@ -97,8 +97,8 @@ export const MAX_CONCURRENT_LIMBO_RESOLUTIONS = 100; export class FirestoreClient { private user = User.UNAUTHENTICATED; private readonly clientId = AutoId.newId(); - private credentialListener: CredentialChangeListener = () => {}; - private readonly receivedInitialUser = new Deferred(); + private credentialListener: CredentialChangeListener = () => + Promise.resolve(); offlineComponents?: OfflineComponentProvider; onlineComponents?: OnlineComponentProvider; @@ -116,17 +116,14 @@ export class FirestoreClient { public asyncQueue: AsyncQueue, private databaseInfo: DatabaseInfo ) { - this.credentials.setChangeListener(user => { + this.credentials.setChangeListener(asyncQueue, async user => { logDebug(LOG_TAG, 'Received user=', user.uid); + await this.credentialListener(user); this.user = user; - this.credentialListener(user); - this.receivedInitialUser.resolve(); }); } async getConfiguration(): Promise { - await this.receivedInitialUser.promise; - return { asyncQueue: this.asyncQueue, databaseInfo: this.databaseInfo, @@ -137,12 +134,8 @@ export class FirestoreClient { }; } - setCredentialChangeListener(listener: (user: User) => void): void { + setCredentialChangeListener(listener: (user: User) => Promise): void { this.credentialListener = listener; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.receivedInitialUser.promise.then(() => - this.credentialListener(this.user) - ); } /** @@ -198,15 +191,13 @@ export async function setOfflineComponentProvider( await offlineComponentProvider.initialize(configuration); let currentUser = configuration.initialUser; - client.setCredentialChangeListener(user => { + client.setCredentialChangeListener(async user => { if (!currentUser.isEqual(user)) { + await localStoreHandleUserChange( + offlineComponentProvider.localStore, + user + ); currentUser = user; - client.asyncQueue.enqueueRetryable(async () => { - await localStoreHandleUserChange( - offlineComponentProvider.localStore, - user - ); - }); } }); @@ -236,12 +227,7 @@ export async function setOnlineComponentProvider( // The CredentialChangeListener of the online component provider takes // precedence over the offline component provider. client.setCredentialChangeListener(user => - client.asyncQueue.enqueueRetryable(() => - remoteStoreHandleCredentialChange( - onlineComponentProvider.remoteStore, - user - ) - ) + remoteStoreHandleCredentialChange(onlineComponentProvider.remoteStore, user) ); client.onlineComponents = onlineComponentProvider; } diff --git a/packages/firestore/src/core/version.ts b/packages/firestore/src/core/version.ts index 5d6dc71524e..27f3ffc5589 100644 --- a/packages/firestore/src/core/version.ts +++ b/packages/firestore/src/core/version.ts @@ -16,4 +16,8 @@ */ /** The semver (www.semver.org) version of the SDK. */ -export { version as SDK_VERSION } from '../../../firebase/package.json'; +import { version } from '../../../firebase/package.json'; +export let SDK_VERSION = version; +export function setSDKVersion(version: string): void { + SDK_VERSION = version; +} diff --git a/packages/firestore/src/exp/database.ts b/packages/firestore/src/exp/database.ts index c79f23a28f0..76df6a72e9f 100644 --- a/packages/firestore/src/exp/database.ts +++ b/packages/firestore/src/exp/database.ts @@ -80,6 +80,8 @@ export const CACHE_SIZE_UNLIMITED = LRU_COLLECTION_DISABLED; * Do not call this constructor directly. Instead, use {@link getFirestore}. */ export class FirebaseFirestore extends LiteFirestore { + type: 'firestore-lite' | 'firestore' = 'firestore'; + readonly _queue: AsyncQueue = newAsyncQueue(); readonly _persistenceKey: string; diff --git a/packages/firestore/src/exp/reference_impl.ts b/packages/firestore/src/exp/reference_impl.ts index ec41ed73dd9..0eee61c267d 100644 --- a/packages/firestore/src/exp/reference_impl.ts +++ b/packages/firestore/src/exp/reference_impl.ts @@ -143,7 +143,7 @@ export function getDocFromCache( doc !== null && doc.hasLocalMutations, /* fromCache= */ true ), - reference._converter + reference.converter ) ); } @@ -270,7 +270,7 @@ export function setDoc( const firestore = cast(reference.firestore, FirebaseFirestore); const convertedValue = applyFirestoreDataConverter( - reference._converter, + reference.converter, data, options ); @@ -280,7 +280,7 @@ export function setDoc( 'setDoc', reference._key, convertedValue, - reference._converter !== null, + reference.converter !== null, options ); @@ -398,10 +398,7 @@ export function addDoc( const firestore = cast(reference.firestore, FirebaseFirestore); const docRef = doc(reference); - const convertedValue = applyFirestoreDataConverter( - reference._converter, - data - ); + const convertedValue = applyFirestoreDataConverter(reference.converter, data); const dataReader = newUserDataReader(reference.firestore); const parsed = parseSetData( @@ -409,7 +406,7 @@ export function addDoc( 'addDoc', docRef._key, convertedValue, - reference._converter !== null, + reference.converter !== null, {} ); @@ -794,6 +791,6 @@ function convertToDocSnapshot( ref._key, doc, new SnapshotMetadata(snapshot.hasPendingWrites, snapshot.fromCache), - ref._converter + ref.converter ); } diff --git a/packages/firestore/src/exp/snapshot.ts b/packages/firestore/src/exp/snapshot.ts index f09994a9301..c7a1f499527 100644 --- a/packages/firestore/src/exp/snapshot.ts +++ b/packages/firestore/src/exp/snapshot.ts @@ -427,7 +427,7 @@ export class QuerySnapshot { this._snapshot.mutatedKeys.has(doc.key), this._snapshot.fromCache ), - this.query._converter + this.query.converter ) ); }); @@ -497,7 +497,7 @@ export function changesFromSnapshot( querySnapshot._snapshot.mutatedKeys.has(change.doc.key), querySnapshot._snapshot.fromCache ), - querySnapshot.query._converter + querySnapshot.query.converter ); lastDoc = change.doc; return { @@ -525,7 +525,7 @@ export function changesFromSnapshot( querySnapshot._snapshot.mutatedKeys.has(change.doc.key), querySnapshot._snapshot.fromCache ), - querySnapshot.query._converter + querySnapshot.query.converter ); let oldIndex = -1; let newIndex = -1; diff --git a/packages/firestore/src/exp/transaction.ts b/packages/firestore/src/exp/transaction.ts index c1790b472fd..f8374f479b0 100644 --- a/packages/firestore/src/exp/transaction.ts +++ b/packages/firestore/src/exp/transaction.ts @@ -66,7 +66,7 @@ export class Transaction extends LiteTransaction { /* hasPendingWrites= */ false, /* fromCache= */ false ), - ref._converter + ref.converter ) ); } diff --git a/packages/firestore/src/lite/database.ts b/packages/firestore/src/lite/database.ts index 4d4ef42e6c9..43730105de5 100644 --- a/packages/firestore/src/lite/database.ts +++ b/packages/firestore/src/lite/database.ts @@ -24,13 +24,17 @@ import { } from '@firebase/app-exp'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; +import { createMockUserToken, EmulatorMockTokenOptions } from '@firebase/util'; import { CredentialsProvider, EmptyCredentialsProvider, + EmulatorCredentialsProvider, FirebaseCredentialsProvider, - makeCredentialsProvider + makeCredentialsProvider, + OAuthToken } from '../api/credentials'; +import { User } from '../auth/user'; import { DatabaseId } from '../core/database_info'; import { Code, FirestoreError } from '../util/error'; import { cast } from '../util/input_validation'; @@ -56,6 +60,8 @@ declare module '@firebase/component' { * Do not call this constructor directly. Instead, use {@link getFirestore}. */ export class FirebaseFirestore implements FirestoreService { + type: 'firestore-lite' | 'firestore' = 'firestore-lite'; + readonly _databaseId: DatabaseId; readonly _persistenceKey: string = '(lite)'; _credentials: CredentialsProvider; @@ -224,11 +230,16 @@ export function getFirestore(app: FirebaseApp = getApp()): FirebaseFirestore { * emulator. * @param host - the emulator host (ex: localhost). * @param port - the emulator port (ex: 9000). + * @param options.mockUserToken - the mock auth token to use for unit testing + * Security Rules. */ export function useFirestoreEmulator( firestore: FirebaseFirestore, host: string, - port: number + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions; + } = {} ): void { firestore = cast(firestore, FirebaseFirestore); const settings = firestore._getSettings(); @@ -245,6 +256,23 @@ export function useFirestoreEmulator( host: `${host}:${port}`, ssl: false }); + + if (options.mockUserToken) { + // Let createMockUserToken validate first (catches common mistakes like + // invalid field "uid" and missing field "sub" / "user_id".) + const token = createMockUserToken(options.mockUserToken); + const uid = options.mockUserToken.sub || options.mockUserToken.user_id; + if (!uid) { + throw new FirestoreError( + Code.INVALID_ARGUMENT, + "mockUserToken must contain 'sub' or 'user_id' field!" + ); + } + + firestore._credentials = new EmulatorCredentialsProvider( + new OAuthToken(token, new User(uid)) + ); + } } /** diff --git a/packages/firestore/src/lite/query.ts b/packages/firestore/src/lite/query.ts index c7cdb576412..b0bc324a25d 100644 --- a/packages/firestore/src/lite/query.ts +++ b/packages/firestore/src/lite/query.ts @@ -147,7 +147,7 @@ class QueryFilterConstraint extends QueryConstraint { ); return new Query( query.firestore, - query._converter, + query.converter, queryWithAddedFilter(query._query, filter) ); } @@ -205,7 +205,7 @@ class QueryOrderByConstraint extends QueryConstraint { const orderBy = newQueryOrderBy(query._query, this._field, this._direction); return new Query( query.firestore, - query._converter, + query.converter, queryWithAddedOrderBy(query._query, orderBy) ); } @@ -247,7 +247,7 @@ class QueryLimitConstraint extends QueryConstraint { _apply(query: Query): Query { return new Query( query.firestore, - query._converter, + query.converter, queryWithLimit(query._query, this._limit, this._limitType) ); } @@ -296,7 +296,7 @@ class QueryStartAtConstraint extends QueryConstraint { ); return new Query( query.firestore, - query._converter, + query.converter, queryWithStartAt(query._query, bound) ); } @@ -378,7 +378,7 @@ class QueryEndAtConstraint extends QueryConstraint { ); return new Query( query.firestore, - query._converter, + query.converter, queryWithEndAt(query._query, bound) ); } diff --git a/packages/firestore/src/lite/reference.ts b/packages/firestore/src/lite/reference.ts index e97c9eebc7b..44933301a02 100644 --- a/packages/firestore/src/lite/reference.ts +++ b/packages/firestore/src/lite/reference.ts @@ -98,7 +98,10 @@ export class DocumentReference { /** @hideconstructor */ constructor( firestore: FirebaseFirestore, - readonly _converter: FirestoreDataConverter | null, + /** + * If provided, the `FirestoreDataConverter` associated with this instance. + */ + readonly converter: FirestoreDataConverter | null, readonly _key: DocumentKey ) { this.firestore = firestore; @@ -129,7 +132,7 @@ export class DocumentReference { get parent(): CollectionReference { return new CollectionReference( this.firestore, - this._converter, + this.converter, this._key.path.popLast() ); } @@ -178,7 +181,10 @@ export class Query { /** @hideconstructor protected */ constructor( firestore: FirebaseFirestore, - readonly _converter: FirestoreDataConverter | null, + /** + * If provided, the `FirestoreDataConverter` associated with this instance. + */ + readonly converter: FirestoreDataConverter | null, readonly _query: InternalQuery ) { this.firestore = firestore; @@ -502,7 +508,7 @@ export function doc( validateDocumentPath(absolutePath); return new DocumentReference( parent.firestore, - parent instanceof CollectionReference ? parent._converter : null, + parent instanceof CollectionReference ? parent.converter : null, new DocumentKey(absolutePath) ); } @@ -531,7 +537,7 @@ export function refEqual( return ( left.firestore === right.firestore && left.path === right.path && - left._converter === right._converter + left.converter === right.converter ); } return false; @@ -554,7 +560,7 @@ export function queryEqual(left: Query, right: Query): boolean { return ( left.firestore === right.firestore && queryEquals(left._query, right._query) && - left._converter === right._converter + left.converter === right.converter ); } return false; diff --git a/packages/firestore/src/lite/reference_impl.ts b/packages/firestore/src/lite/reference_impl.ts index 9385b0de363..91c59bbb1b3 100644 --- a/packages/firestore/src/lite/reference_impl.ts +++ b/packages/firestore/src/lite/reference_impl.ts @@ -134,7 +134,7 @@ export function getDoc( userDataWriter, reference._key, document.isFoundDocument() ? document : null, - reference._converter + reference.converter ); } ); @@ -166,7 +166,7 @@ export function getDocs(query: Query): Promise> { userDataWriter, doc.key, doc, - query._converter + query.converter ) ); @@ -227,7 +227,7 @@ export function setDoc( ): Promise { reference = cast>(reference, DocumentReference); const convertedValue = applyFirestoreDataConverter( - reference._converter, + reference.converter, data, options ); @@ -237,7 +237,7 @@ export function setDoc( 'setDoc', reference._key, convertedValue, - reference._converter !== null, + reference.converter !== null, options ); @@ -378,10 +378,7 @@ export function addDoc( reference = cast>(reference, CollectionReference); const docRef = doc(reference); - const convertedValue = applyFirestoreDataConverter( - reference._converter, - data - ); + const convertedValue = applyFirestoreDataConverter(reference.converter, data); const dataReader = newUserDataReader(reference.firestore); const parsed = parseSetData( @@ -389,7 +386,7 @@ export function addDoc( 'addDoc', docRef._key, convertedValue, - docRef._converter !== null, + docRef.converter !== null, {} ); diff --git a/packages/firestore/src/lite/transaction.ts b/packages/firestore/src/lite/transaction.ts index e1182410e51..24d4dbc18c5 100644 --- a/packages/firestore/src/lite/transaction.ts +++ b/packages/firestore/src/lite/transaction.ts @@ -88,7 +88,7 @@ export class Transaction { userDataWriter, doc.key, doc, - ref._converter + ref.converter ); } else if (doc.isNoDocument()) { return new DocumentSnapshot( @@ -96,7 +96,7 @@ export class Transaction { userDataWriter, ref._key, null, - ref._converter + ref.converter ); } else { throw fail( @@ -138,7 +138,7 @@ export class Transaction { ): this { const ref = validateReference(documentRef, this._firestore); const convertedValue = applyFirestoreDataConverter( - ref._converter, + ref.converter, value, options ); @@ -147,7 +147,7 @@ export class Transaction { 'Transaction.set', ref._key, convertedValue, - ref._converter !== null, + ref.converter !== null, options ); this._transaction.set(ref._key, parsed); diff --git a/packages/firestore/src/lite/write_batch.ts b/packages/firestore/src/lite/write_batch.ts index a60c6a79d00..b3e8f681d76 100644 --- a/packages/firestore/src/lite/write_batch.ts +++ b/packages/firestore/src/lite/write_batch.ts @@ -93,7 +93,7 @@ export class WriteBatch { const ref = validateReference(documentRef, this._firestore); const convertedValue = applyFirestoreDataConverter( - ref._converter, + ref.converter, data, options ); @@ -102,7 +102,7 @@ export class WriteBatch { 'WriteBatch.set', ref._key, convertedValue, - ref._converter !== null, + ref.converter !== null, options ); this._mutations.push(parsed.toMutation(ref._key, Precondition.none())); diff --git a/packages/firestore/src/local/indexeddb_persistence.ts b/packages/firestore/src/local/indexeddb_persistence.ts index 782155898be..859505f8dcf 100644 --- a/packages/firestore/src/local/indexeddb_persistence.ts +++ b/packages/firestore/src/local/indexeddb_persistence.ts @@ -966,7 +966,7 @@ export class IndexedDbPersistence implements Persistence { return this.shutdown(); }); }; - this.window.addEventListener('unload', this.windowUnloadHandler); + this.window.addEventListener('pagehide', this.windowUnloadHandler); } } @@ -976,7 +976,7 @@ export class IndexedDbPersistence implements Persistence { typeof this.window?.removeEventListener === 'function', "Expected 'window.removeEventListener' to be a function" ); - this.window!.removeEventListener('unload', this.windowUnloadHandler); + this.window!.removeEventListener('pagehide', this.windowUnloadHandler); this.windowUnloadHandler = null; } } diff --git a/packages/firestore/src/local/shared_client_state.ts b/packages/firestore/src/local/shared_client_state.ts index f310e286461..a3deb3c5179 100644 --- a/packages/firestore/src/local/shared_client_state.ts +++ b/packages/firestore/src/local/shared_client_state.ts @@ -613,7 +613,7 @@ export class WebStorageSharedClientState implements SharedClientState { // Register a window unload hook to remove the client metadata entry from // WebStorage even if `shutdown()` was not called. - this.window.addEventListener('unload', () => this.shutdown()); + this.window.addEventListener('pagehide', () => this.shutdown()); this.started = true; } diff --git a/packages/firestore/src/platform/node/grpc_connection.ts b/packages/firestore/src/platform/node/grpc_connection.ts index 1056c6694bf..c217ec16658 100644 --- a/packages/firestore/src/platform/node/grpc_connection.ts +++ b/packages/firestore/src/platform/node/grpc_connection.ts @@ -56,7 +56,9 @@ function createMetadata( } } } - metadata.set('X-Firebase-GMPID', appId); + if (appId) { + metadata.set('X-Firebase-GMPID', appId); + } metadata.set('X-Goog-Api-Client', X_GOOG_API_CLIENT_VALUE); // This header is used to improve routing and project isolation by the // backend. diff --git a/packages/firestore/src/remote/datastore.ts b/packages/firestore/src/remote/datastore.ts index 628a3a1bfd3..944a59e5b4c 100644 --- a/packages/firestore/src/remote/datastore.ts +++ b/packages/firestore/src/remote/datastore.ts @@ -99,10 +99,14 @@ class DatastoreImpl extends Datastore { ); }) .catch((error: FirestoreError) => { - if (error.code === Code.UNAUTHENTICATED) { - this.credentials.invalidateToken(); + if (error.name === 'FirebaseError') { + if (error.code === Code.UNAUTHENTICATED) { + this.credentials.invalidateToken(); + } + throw error; + } else { + throw new FirestoreError(Code.UNKNOWN, error.toString()); } - throw error; }); } @@ -124,15 +128,19 @@ class DatastoreImpl extends Datastore { ); }) .catch((error: FirestoreError) => { - if (error.code === Code.UNAUTHENTICATED) { - this.credentials.invalidateToken(); + if (error.name === 'FirebaseError') { + if (error.code === Code.UNAUTHENTICATED) { + this.credentials.invalidateToken(); + } + throw error; + } else { + throw new FirestoreError(Code.UNKNOWN, error.toString()); } - throw error; }); } terminate(): void { - this.terminated = false; + this.terminated = true; } } diff --git a/packages/firestore/src/remote/rest_connection.ts b/packages/firestore/src/remote/rest_connection.ts index c52138e5fa2..2f4b75e192b 100644 --- a/packages/firestore/src/remote/rest_connection.ts +++ b/packages/firestore/src/remote/rest_connection.ts @@ -39,8 +39,12 @@ RPC_NAME_URL_MAPPING['Commit'] = 'commit'; RPC_NAME_URL_MAPPING['RunQuery'] = 'runQuery'; const RPC_URL_VERSION = 'v1'; -const X_GOOG_API_CLIENT_VALUE = 'gl-js/ fire/' + SDK_VERSION; +// SDK_VERSION is updated to different value at runtime depending on the entry point, +// so we need to get its value when we need it in a function. +function getGoogApiClientValue(): string { + return 'gl-js/ fire/' + SDK_VERSION; +} /** * Base class for all Rest-based connections to the backend (WebChannel and * HTTP). @@ -118,8 +122,7 @@ export abstract class RestConnection implements Connection { headers: StringMap, token: Token | null ): void { - headers['X-Goog-Api-Client'] = X_GOOG_API_CLIENT_VALUE; - headers['X-Firebase-GMPID'] = this.databaseInfo.appId; + headers['X-Goog-Api-Client'] = getGoogApiClientValue(); // Content-Type: text/plain will avoid preflight requests which might // mess with CORS and redirects by proxies. If we add custom headers @@ -127,6 +130,9 @@ export abstract class RestConnection implements Connection { // parameter supported by ESF to avoid triggering preflight requests. headers['Content-Type'] = 'text/plain'; + if (this.databaseInfo.appId) { + headers['X-Firebase-GMPID'] = this.databaseInfo.appId; + } if (token) { for (const header in token.authHeaders) { if (token.authHeaders.hasOwnProperty(header)) { diff --git a/packages/firestore/test/integration/api/validation.test.ts b/packages/firestore/test/integration/api/validation.test.ts index ec56d625bee..b69f309cf17 100644 --- a/packages/firestore/test/integration/api/validation.test.ts +++ b/packages/firestore/test/integration/api/validation.test.ts @@ -157,6 +157,24 @@ apiDescribe('Validation:', (persistence: boolean) => { expect(() => db.useEmulator('localhost', 9000)).to.throw(errorMsg); } ); + + validationIt(persistence, 'useEmulator can set mockUserToken', () => { + const db = newTestFirestore('test-project'); + // Verify that this doesn't throw. + db.useEmulator('localhost', 9000, { mockUserToken: { sub: 'foo' } }); + }); + + validationIt( + persistence, + 'throws if sub / user_id is missing in mockUserToken', + async db => { + const errorMsg = "mockUserToken must contain 'sub' or 'user_id' field!"; + + expect(() => + db.useEmulator('localhost', 9000, { mockUserToken: {} as any }) + ).to.throw(errorMsg); + } + ); }); describe('Firestore', () => { diff --git a/packages/firestore/test/integration/util/internal_helpers.ts b/packages/firestore/test/integration/util/internal_helpers.ts index 181b2a41bc7..4ac848d02b5 100644 --- a/packages/firestore/test/integration/util/internal_helpers.ts +++ b/packages/firestore/test/integration/util/internal_helpers.ts @@ -28,6 +28,7 @@ import { DatabaseId, DatabaseInfo } from '../../../src/core/database_info'; import { newConnection } from '../../../src/platform/connection'; import { newSerializer } from '../../../src/platform/serializer'; import { newDatastore, Datastore } from '../../../src/remote/datastore'; +import { AsyncQueue } from '../../../src/util/async_queue'; import { AsyncQueueImpl } from '../../../src/util/async_queue_impl'; import { TestBundleBuilder } from '../../unit/util/bundle_data'; import { collectionReference } from '../../util/api_helpers'; @@ -65,13 +66,18 @@ export function withTestDatastore( export class MockCredentialsProvider extends EmptyCredentialsProvider { private listener: CredentialChangeListener | null = null; + private asyncQueue: AsyncQueue | null = null; triggerUserChange(newUser: User): void { - this.listener!(newUser); + this.asyncQueue!.enqueueRetryable(async () => this.listener!(newUser)); } - setChangeListener(listener: CredentialChangeListener): void { - super.setChangeListener(listener); + setChangeListener( + asyncQueue: AsyncQueue, + listener: CredentialChangeListener + ): void { + super.setChangeListener(asyncQueue, listener); + this.asyncQueue = asyncQueue; this.listener = listener; } } diff --git a/packages/firestore/test/unit/api/database.test.ts b/packages/firestore/test/unit/api/database.test.ts index f01c5d509eb..8ba1d406a6d 100644 --- a/packages/firestore/test/unit/api/database.test.ts +++ b/packages/firestore/test/unit/api/database.test.ts @@ -17,6 +17,7 @@ import { expect } from 'chai'; +import { EmulatorCredentialsProvider } from '../../../src/api/credentials'; import { collectionReference, documentReference, @@ -250,4 +251,17 @@ describe('Settings', () => { expect(db._delegate._getSettings().host).to.equal('localhost:9000'); expect(db._delegate._getSettings().ssl).to.be.false; }); + + it('sets credentials based on mockUserToken', async () => { + // Use a new instance of Firestore in order to configure settings. + const db = newTestFirestore(); + const mockUserToken = { sub: 'foobar' }; + db.useEmulator('localhost', 9000, { mockUserToken }); + + const credentials = db._delegate._credentials; + expect(credentials).to.be.instanceOf(EmulatorCredentialsProvider); + const token = await credentials.getToken(); + expect(token!.type).to.eql('OAuth'); + expect(token!.user.uid).to.eql(mockUserToken.sub); + }); }); diff --git a/packages/firestore/test/unit/remote/datastore.test.ts b/packages/firestore/test/unit/remote/datastore.test.ts new file mode 100644 index 00000000000..45e0dbf8843 --- /dev/null +++ b/packages/firestore/test/unit/remote/datastore.test.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2021 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 { EmptyCredentialsProvider, Token } from '../../../src/api/credentials'; +import { DatabaseId } from '../../../src/core/database_info'; +import { Connection, Stream } from '../../../src/remote/connection'; +import { + Datastore, + newDatastore, + invokeCommitRpc, + invokeBatchGetDocumentsRpc +} from '../../../src/remote/datastore'; +import { JsonProtoSerializer } from '../../../src/remote/serializer'; +import { Code, FirestoreError } from '../../../src/util/error'; + +use(chaiAsPromised); + +// TODO(b/185584343): Improve the coverage of these tests. +// At the time of writing, the tests only cover the error handling in +// `invokeRPC()` and `invokeStreamingRPC()`. +describe('Datastore', () => { + class MockConnection implements Connection { + invokeRPC( + rpcName: string, + path: string, + request: Req, + token: Token | null + ): Promise { + throw new Error('MockConnection.invokeRPC() must be replaced'); + } + + invokeStreamingRPC( + rpcName: string, + path: string, + request: Req, + token: Token | null + ): Promise { + throw new Error('MockConnection.invokeStreamingRPC() must be replaced'); + } + + openStream( + rpcName: string, + token: Token | null + ): Stream { + throw new Error('MockConnection.openStream() must be replaced'); + } + } + + class MockCredentialsProvider extends EmptyCredentialsProvider { + invalidateTokenInvoked = false; + invalidateToken(): void { + this.invalidateTokenInvoked = true; + } + } + + const serializer = new JsonProtoSerializer( + new DatabaseId('test-project'), + /* useProto3Json= */ false + ); + + async function invokeDatastoreImplInvokeRpc( + datastore: Datastore + ): Promise { + // Since we cannot access the `DatastoreImpl` class directly, invoke its + // `invokeRPC()` method indirectly via `invokeCommitRpc()`. + await invokeCommitRpc(datastore, /* mutations= */ []); + } + + async function invokeDatastoreImplInvokeStreamingRPC( + datastore: Datastore + ): Promise { + // Since we cannot access the `DatastoreImpl` class directly, invoke its + // `invokeStreamingRPC()` method indirectly via + // `invokeBatchGetDocumentsRpc()`. + await invokeBatchGetDocumentsRpc(datastore, /* keys= */ []); + } + + it('newDatastore() returns an an instance of Datastore', () => { + const datastore = newDatastore( + new EmptyCredentialsProvider(), + new MockConnection(), + serializer + ); + expect(datastore).to.be.an.instanceof(Datastore); + }); + + it('DatastoreImpl.invokeRPC() fails if terminated', async () => { + const datastore = newDatastore( + new EmptyCredentialsProvider(), + new MockConnection(), + serializer + ); + datastore.terminate(); + await expect(invokeDatastoreImplInvokeRpc(datastore)) + .to.eventually.be.rejectedWith(/terminated/) + .and.include({ + 'name': 'FirebaseError', + 'code': Code.FAILED_PRECONDITION + }); + }); + + it('DatastoreImpl.invokeRPC() rethrows a FirestoreError', async () => { + const connection = new MockConnection(); + connection.invokeRPC = () => + Promise.reject(new FirestoreError(Code.ABORTED, 'zzyzx')); + const credentials = new MockCredentialsProvider(); + const datastore = newDatastore(credentials, connection, serializer); + await expect(invokeDatastoreImplInvokeRpc(datastore)) + .to.eventually.be.rejectedWith('zzyzx') + .and.include({ + 'name': 'FirebaseError', + 'code': Code.ABORTED + }); + expect(credentials.invalidateTokenInvoked).to.be.false; + }); + + it('DatastoreImpl.invokeRPC() wraps unknown exceptions in a FirestoreError', async () => { + const connection = new MockConnection(); + connection.invokeRPC = () => Promise.reject('zzyzx'); + const credentials = new MockCredentialsProvider(); + const datastore = newDatastore(credentials, connection, serializer); + await expect(invokeDatastoreImplInvokeRpc(datastore)) + .to.eventually.be.rejectedWith('zzyzx') + .and.include({ + 'name': 'FirebaseError', + 'code': Code.UNKNOWN + }); + expect(credentials.invalidateTokenInvoked).to.be.false; + }); + + it('DatastoreImpl.invokeRPC() invalidates the token if unauthenticated', async () => { + const connection = new MockConnection(); + connection.invokeRPC = () => + Promise.reject(new FirestoreError(Code.UNAUTHENTICATED, 'zzyzx')); + const credentials = new MockCredentialsProvider(); + const datastore = newDatastore(credentials, connection, serializer); + await expect(invokeDatastoreImplInvokeRpc(datastore)) + .to.eventually.be.rejectedWith('zzyzx') + .and.include({ + 'name': 'FirebaseError', + 'code': Code.UNAUTHENTICATED + }); + expect(credentials.invalidateTokenInvoked).to.be.true; + }); + + it('DatastoreImpl.invokeStreamingRPC() fails if terminated', async () => { + const datastore = newDatastore( + new EmptyCredentialsProvider(), + new MockConnection(), + serializer + ); + datastore.terminate(); + await expect(invokeDatastoreImplInvokeStreamingRPC(datastore)) + .to.eventually.be.rejectedWith(/terminated/) + .and.include({ + 'name': 'FirebaseError', + 'code': Code.FAILED_PRECONDITION + }); + }); + + it('DatastoreImpl.invokeStreamingRPC() rethrows a FirestoreError', async () => { + const connection = new MockConnection(); + connection.invokeStreamingRPC = () => + Promise.reject(new FirestoreError(Code.ABORTED, 'zzyzx')); + const credentials = new MockCredentialsProvider(); + const datastore = newDatastore(credentials, connection, serializer); + await expect(invokeDatastoreImplInvokeStreamingRPC(datastore)) + .to.eventually.be.rejectedWith('zzyzx') + .and.include({ + 'name': 'FirebaseError', + 'code': Code.ABORTED + }); + expect(credentials.invalidateTokenInvoked).to.be.false; + }); + + it('DatastoreImpl.invokeStreamingRPC() wraps unknown exceptions in a FirestoreError', async () => { + const connection = new MockConnection(); + connection.invokeStreamingRPC = () => Promise.reject('zzyzx'); + const credentials = new MockCredentialsProvider(); + const datastore = newDatastore(credentials, connection, serializer); + await expect(invokeDatastoreImplInvokeStreamingRPC(datastore)) + .to.eventually.be.rejectedWith('zzyzx') + .and.include({ + 'name': 'FirebaseError', + 'code': Code.UNKNOWN + }); + expect(credentials.invalidateTokenInvoked).to.be.false; + }); + + it('DatastoreImpl.invokeStreamingRPC() invalidates the token if unauthenticated', async () => { + const connection = new MockConnection(); + connection.invokeStreamingRPC = () => + Promise.reject(new FirestoreError(Code.UNAUTHENTICATED, 'zzyzx')); + const credentials = new MockCredentialsProvider(); + const datastore = newDatastore(credentials, connection, serializer); + await expect(invokeDatastoreImplInvokeStreamingRPC(datastore)) + .to.eventually.be.rejectedWith('zzyzx') + .and.include({ + 'name': 'FirebaseError', + 'code': Code.UNAUTHENTICATED + }); + expect(credentials.invalidateTokenInvoked).to.be.true; + }); +}); diff --git a/packages/firestore/test/util/test_platform.ts b/packages/firestore/test/util/test_platform.ts index 27249def42c..88a71c09a44 100644 --- a/packages/firestore/test/util/test_platform.ts +++ b/packages/firestore/test/util/test_platform.ts @@ -58,7 +58,7 @@ export class FakeWindow implements WindowLike { case 'storage': this.storageListeners.push(listener); break; - case 'unload': + case 'pagehide': case 'visibilitychange': // The spec tests currently do not rely on `unload`/`visibilitychange` // listeners. diff --git a/packages/functions/CHANGELOG.md b/packages/functions/CHANGELOG.md index ac48c2ad7e1..35e2b316412 100644 --- a/packages/functions/CHANGELOG.md +++ b/packages/functions/CHANGELOG.md @@ -1,5 +1,12 @@ # @firebase/functions +## 0.6.8 + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae)]: + - @firebase/component@0.5.0 + ## 0.6.7 ### Patch Changes diff --git a/packages/functions/package.json b/packages/functions/package.json index 78649c99595..2e4ea5b1364 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/functions", - "version": "0.6.7", + "version": "0.6.8", "description": "", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.node.cjs.js", @@ -30,8 +30,8 @@ "@firebase/app-types": "0.x" }, "devDependencies": { - "@firebase/app": "0.6.20", - "@firebase/messaging": "0.7.9", + "@firebase/app": "0.6.22", + "@firebase/messaging": "0.7.10", "rollup": "2.35.1", "rollup-plugin-typescript2": "0.29.0", "typescript": "4.2.2" @@ -46,7 +46,7 @@ }, "typings": "dist/index.d.ts", "dependencies": { - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "@firebase/functions-types": "0.4.0", "@firebase/messaging-types": "0.5.0", "node-fetch": "2.6.1", diff --git a/packages/functions/src/api/service.ts b/packages/functions/src/api/service.ts index 9c386e604a9..6ab18741bac 100644 --- a/packages/functions/src/api/service.ts +++ b/packages/functions/src/api/service.ts @@ -29,6 +29,7 @@ import { Serializer } from '../serializer'; import { Provider } from '@firebase/component'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { FirebaseMessagingName } from '@firebase/messaging-types'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; /** * The response to an http request. @@ -100,6 +101,7 @@ export class Service implements FirebaseFunctions, FirebaseService { private app_: FirebaseApp, authProvider: Provider, messagingProvider: Provider, + private appCheckProvider: Provider, regionOrCustomDomain_: string = 'us-central1', readonly fetchImpl: typeof fetch ) { @@ -198,6 +200,11 @@ export class Service implements FirebaseFunctions, FirebaseService { ): Promise { headers['Content-Type'] = 'application/json'; + const appCheckToken = await this.getAppCheckToken(); + if (appCheckToken !== null) { + headers['X-Firebase-AppCheck'] = appCheckToken; + } + let response: Response; try { response = await this.fetchImpl(url, { @@ -227,6 +234,19 @@ export class Service implements FirebaseFunctions, FirebaseService { }; } + private async getAppCheckToken(): Promise { + const appCheck = this.appCheckProvider.getImmediate({ optional: true }); + if (appCheck) { + const result = await appCheck.getToken(); + // If getToken() fails, it will still return a dummy token that also has + // an error field containing the error message. We will send any token + // provided here and show an error if/when it is rejected by the functions + // endpoint. + return result.token; + } + return null; + } + /** * Calls a callable function asynchronously and returns the result. * @param name The name of the callable trigger. diff --git a/packages/functions/src/config.ts b/packages/functions/src/config.ts index 943630f8640..b701aec8caf 100644 --- a/packages/functions/src/config.ts +++ b/packages/functions/src/config.ts @@ -45,6 +45,7 @@ export function registerFunctions( // Dependencies const app = container.getProvider('app').getImmediate(); const authProvider = container.getProvider('auth-internal'); + const appCheckProvider = container.getProvider('app-check-internal'); const messagingProvider = container.getProvider('messaging'); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -52,6 +53,7 @@ export function registerFunctions( app, authProvider, messagingProvider, + appCheckProvider, regionOrCustomDomain, fetchImpl ); diff --git a/packages/functions/src/context.ts b/packages/functions/src/context.ts index 8f6923853f8..71b66bca99d 100644 --- a/packages/functions/src/context.ts +++ b/packages/functions/src/context.ts @@ -1,3 +1,7 @@ +import { + FirebaseAuthInternal, + FirebaseAuthInternalName +} from '@firebase/auth-interop-types'; /** * @license * Copyright 2017 Google LLC @@ -18,10 +22,7 @@ import { FirebaseMessaging, FirebaseMessagingName } from '@firebase/messaging-types'; -import { - FirebaseAuthInternal, - FirebaseAuthInternalName -} from '@firebase/auth-interop-types'; + import { Provider } from '@firebase/component'; /** @@ -93,7 +94,7 @@ export class ContextProvider { } try { - return this.messaging.getToken(); + return await this.messaging.getToken(); } catch (e) { // We don't warn on this, because it usually means messaging isn't set up. // console.warn('Failed to retrieve instance id token.', e); diff --git a/packages/functions/test/callable.test.ts b/packages/functions/test/callable.test.ts index 7f86e2e5b97..2a5efa27f98 100644 --- a/packages/functions/test/callable.test.ts +++ b/packages/functions/test/callable.test.ts @@ -28,7 +28,8 @@ import { FirebaseAuthInternal, FirebaseAuthInternalName } from '@firebase/auth-interop-types'; -import { makeFakeApp, createTestService } from './utils'; +import { makeFakeApp, createTestService, getFetchImpl } from './utils'; +import { spy } from 'sinon'; // eslint-disable-next-line @typescript-eslint/no-require-imports export const TEST_PROJECT = require('../../../config/project.json'); @@ -58,6 +59,7 @@ async function expectError( describe('Firebase Functions > Call', () => { let app: FirebaseApp; const region = 'us-central1'; + let fetchSpy: sinon.SinonSpy = spy(); before(() => { const useEmulator = !!process.env.FIREBASE_FUNCTIONS_EMULATOR_ORIGIN; @@ -69,6 +71,33 @@ describe('Firebase Functions > Call', () => { app = makeFakeApp({ projectId, messagingSenderId }); }); + it('sends app check header', async () => { + const spyFetchImpl = getFetchImpl(); + fetchSpy = spy(spyFetchImpl); + const functions = createTestService( + app, + region, + undefined, + undefined, + 'not-dummy-token', + fetchSpy + ); + const data = { + bool: true, + int: 2, + str: 'four', + array: [5, 6], + null: null + }; + + const func = functions.httpsCallable('dataTest'); + await func(data); + + expect(fetchSpy.args[0][1].headers['X-Firebase-AppCheck']).to.equal( + 'not-dummy-token' + ); + }); + it('simple data', async () => { const functions = createTestService(app, region); // TODO(klimt): Should we add an API to create a "long" in JS? diff --git a/packages/functions/test/utils.ts b/packages/functions/test/utils.ts index 4ffdd8c868a..2a9f725a470 100644 --- a/packages/functions/test/utils.ts +++ b/packages/functions/test/utils.ts @@ -16,9 +16,15 @@ */ import { FirebaseOptions, FirebaseApp } from '@firebase/app-types'; -import { Provider, ComponentContainer } from '@firebase/component'; +import { + Provider, + ComponentContainer, + Component, + ComponentType +} from '@firebase/component'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { FirebaseMessagingName } from '@firebase/messaging-types'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { Service } from '../src/api/service'; import nodeFetch from 'node-fetch'; @@ -41,6 +47,12 @@ export function makeFakeApp(options: FirebaseOptions = {}): FirebaseApp { }; } +export function getFetchImpl(): typeof fetch { + return typeof window !== 'undefined' + ? fetch.bind(window) + : (nodeFetch as any); +} + export function createTestService( app: FirebaseApp, regionOrCustomDomain?: string, @@ -51,14 +63,30 @@ export function createTestService( messagingProvider = new Provider( 'messaging', new ComponentContainer('test') - ) + ), + fakeAppCheckToken = 'dummytoken', + fetchImpl = getFetchImpl() ): Service { - const fetchImpl: typeof fetch = - typeof window !== 'undefined' ? fetch.bind(window) : (nodeFetch as any); + const appCheckProvider = new Provider( + 'app-check-internal', + new ComponentContainer('test') + ); + appCheckProvider.setComponent( + new Component( + 'app-check-internal', + () => { + return { + getToken: () => Promise.resolve({ token: fakeAppCheckToken }) + } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + }, + ComponentType.PRIVATE + ) + ); const functions = new Service( app, authProvider, messagingProvider, + appCheckProvider, regionOrCustomDomain, fetchImpl ); diff --git a/packages/installations/CHANGELOG.md b/packages/installations/CHANGELOG.md index adba7a05fb5..c3e86ffa74e 100644 --- a/packages/installations/CHANGELOG.md +++ b/packages/installations/CHANGELOG.md @@ -1,5 +1,13 @@ # @firebase/installations +## 0.4.26 + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/util@1.1.0 + ## 0.4.25 ### Patch Changes diff --git a/packages/installations/package.json b/packages/installations/package.json index 465d566225f..a3177dc423c 100644 --- a/packages/installations/package.json +++ b/packages/installations/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/installations", - "version": "0.4.25", + "version": "0.4.26", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", "esm2017": "dist/index.esm2017.js", @@ -29,7 +29,7 @@ "url": "https://github.com/firebase/firebase-js-sdk/issues" }, "devDependencies": { - "@firebase/app": "0.6.20", + "@firebase/app": "0.6.22", "rollup": "2.35.1", "@rollup/plugin-commonjs": "17.1.0", "@rollup/plugin-json": "4.1.0", @@ -44,8 +44,8 @@ }, "dependencies": { "@firebase/installations-types": "0.3.4", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "idb": "3.0.2", "tslib": "^2.1.0" } diff --git a/packages/installations/src/util/sleep.test.ts b/packages/installations/src/util/sleep.test.ts index 6dfc4b328ee..86ae066fb31 100644 --- a/packages/installations/src/util/sleep.test.ts +++ b/packages/installations/src/util/sleep.test.ts @@ -17,7 +17,6 @@ import { expect } from 'chai'; import { SinonFakeTimers, useFakeTimers } from 'sinon'; -import '../testing/setup'; import { sleep } from './sleep'; describe('sleep', () => { diff --git a/packages/installations/src/util/sleep.ts b/packages/installations/src/util/sleep.ts index 2bd1eb9283b..e9a902de343 100644 --- a/packages/installations/src/util/sleep.ts +++ b/packages/installations/src/util/sleep.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2019 Google LLC + * 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. diff --git a/packages/messaging/CHANGELOG.md b/packages/messaging/CHANGELOG.md index a04e534b322..7ed9c139b23 100644 --- a/packages/messaging/CHANGELOG.md +++ b/packages/messaging/CHANGELOG.md @@ -1,5 +1,14 @@ # @firebase/messaging +## 0.7.10 + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/util@1.1.0 + - @firebase/installations@0.4.26 + ## 0.7.9 ### Patch Changes diff --git a/packages/messaging/package.json b/packages/messaging/package.json index 95daceda166..e0b1d6c5d94 100644 --- a/packages/messaging/package.json +++ b/packages/messaging/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/messaging", - "version": "0.7.9", + "version": "0.7.10", "description": "", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", @@ -27,15 +27,15 @@ "@firebase/app-types": "0.x" }, "dependencies": { - "@firebase/installations": "0.4.25", + "@firebase/installations": "0.4.26", "@firebase/messaging-types": "0.5.0", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "idb": "3.0.2", "tslib": "^2.1.0" }, "devDependencies": { - "@firebase/app": "0.6.20", + "@firebase/app": "0.6.22", "rollup": "2.35.1", "rollup-plugin-typescript2": "0.29.0", "ts-essentials": "7.0.1", diff --git a/packages/performance/CHANGELOG.md b/packages/performance/CHANGELOG.md index 788ccd23a77..6f1f299b2c5 100644 --- a/packages/performance/CHANGELOG.md +++ b/packages/performance/CHANGELOG.md @@ -1,5 +1,14 @@ # @firebase/performance +## 0.4.12 + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/util@1.1.0 + - @firebase/installations@0.4.26 + ## 0.4.11 ### Patch Changes diff --git a/packages/performance/package.json b/packages/performance/package.json index 48ad2376a00..8362f470cc2 100644 --- a/packages/performance/package.json +++ b/packages/performance/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/performance", - "version": "0.4.11", + "version": "0.4.12", "description": "Firebase performance for web", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", @@ -28,15 +28,15 @@ }, "dependencies": { "@firebase/logger": "0.2.6", - "@firebase/installations": "0.4.25", - "@firebase/util": "1.0.0", + "@firebase/installations": "0.4.26", + "@firebase/util": "1.1.0", "@firebase/performance-types": "0.0.13", - "@firebase/component": "0.4.1", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.6.20", + "@firebase/app": "0.6.22", "rollup": "2.35.1", "@rollup/plugin-json": "4.1.0", "rollup-plugin-typescript2": "0.29.0", diff --git a/packages/remote-config/CHANGELOG.md b/packages/remote-config/CHANGELOG.md index cbd33abafe3..0245bdc57b1 100644 --- a/packages/remote-config/CHANGELOG.md +++ b/packages/remote-config/CHANGELOG.md @@ -1,5 +1,14 @@ # @firebase/remote-config +## 0.1.37 + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/util@1.1.0 + - @firebase/installations@0.4.26 + ## 0.1.36 ### Patch Changes diff --git a/packages/remote-config/package.json b/packages/remote-config/package.json index d1e07cd5b11..384c01beaa5 100644 --- a/packages/remote-config/package.json +++ b/packages/remote-config/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/remote-config", - "version": "0.1.36", + "version": "0.1.37", "description": "The Remote Config package of the Firebase JS SDK", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", @@ -27,16 +27,16 @@ "@firebase/app-types": "0.x" }, "dependencies": { - "@firebase/installations": "0.4.25", + "@firebase/installations": "0.4.26", "@firebase/logger": "0.2.6", "@firebase/remote-config-types": "0.1.9", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.6.20", + "@firebase/app": "0.6.22", "rollup": "2.35.1", "rollup-plugin-typescript2": "0.29.0", "typescript": "4.2.2" diff --git a/packages/rules-unit-testing/CHANGELOG.md b/packages/rules-unit-testing/CHANGELOG.md index d9bc33e2e08..0b5d92bd949 100644 --- a/packages/rules-unit-testing/CHANGELOG.md +++ b/packages/rules-unit-testing/CHANGELOG.md @@ -1,5 +1,46 @@ # @firebase/rules-unit-testing +## 1.3.1 + +### Patch Changes + +- Updated dependencies []: + - firebase@8.6.1 + +## 1.3.0 + +### Minor Changes + +- [`66deb252d`](https://github.com/firebase/firebase-js-sdk/commit/66deb252d9aebf318d2410d2dee47f19ad0968da) [#4863](https://github.com/firebase/firebase-js-sdk/pull/4863) - Add support for Storage emulator to rules-unit-testing + +### Patch Changes + +- Updated dependencies [[`cc7207e25`](https://github.com/firebase/firebase-js-sdk/commit/cc7207e25f09870c6c718b8e209e694661676d27), [`81c131abe`](https://github.com/firebase/firebase-js-sdk/commit/81c131abea7001c5933156ff6b0f3925f16ff052)]: + - firebase@8.6.0 + +## 1.2.12 + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`97f61e6f3`](https://github.com/firebase/firebase-js-sdk/commit/97f61e6f3d24e5b4c92ed248bb531233a94b9eaf), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - firebase@8.5.0 + - @firebase/util@1.1.0 + +## 1.2.11 + +### Patch Changes + +- Updated dependencies []: + - firebase@8.4.3 + +## 1.2.10 + +### Patch Changes + +- Updated dependencies []: + - firebase@8.4.2 + ## 1.2.9 ### Patch Changes diff --git a/packages/rules-unit-testing/firebase.json b/packages/rules-unit-testing/firebase.json index 1b237fe5922..2f2052323a5 100644 --- a/packages/rules-unit-testing/firebase.json +++ b/packages/rules-unit-testing/firebase.json @@ -2,6 +2,9 @@ "functions": { "source": "." }, + "storage": { + "rules": "test/storage.rules" + }, "emulators": { "firestore": { "port": 9003 @@ -12,6 +15,9 @@ "functions": { "port": 9004 }, + "storage": { + "port": 9199 + }, "ui": { "enabled": false } diff --git a/packages/rules-unit-testing/index.ts b/packages/rules-unit-testing/index.ts index cfa91149eaa..a8bd500634d 100644 --- a/packages/rules-unit-testing/index.ts +++ b/packages/rules-unit-testing/index.ts @@ -33,6 +33,7 @@ export { initializeTestApp, loadDatabaseRules, loadFirestoreRules, + loadStorageRules, useEmulators, withFunctionTriggersDisabled } from './src/api'; diff --git a/packages/rules-unit-testing/package.json b/packages/rules-unit-testing/package.json index 3f9a089d6c5..61d283b76c1 100644 --- a/packages/rules-unit-testing/package.json +++ b/packages/rules-unit-testing/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/rules-unit-testing", - "version": "1.2.9", + "version": "1.3.1", "description": "", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", @@ -15,29 +15,29 @@ "build:deps": "lerna run --scope @firebase/rules-unit-testing --include-dependencies build", "dev": "rollup -c -w", "test:nyc": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --config ../../config/mocharc.node.js", - "test": "firebase --project=foo --debug emulators:exec 'yarn test:nyc'", + "test": "FIREBASE_CLI_PREVIEWS=storageemulator STORAGE_EMULATOR_HOST=http://localhost:9199 firebase --project=foo --debug emulators:exec 'yarn test:nyc'", "test:ci": "node ../../scripts/run_tests_in_ci.js -s test" }, "license": "Apache-2.0", "dependencies": { - "firebase": "8.4.1", - "@firebase/component": "0.4.1", + "firebase": "8.6.1", + "@firebase/component": "0.5.0", "@firebase/logger": "0.2.6", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "request": "2.88.2" }, "devDependencies": { "@google-cloud/firestore": "4.8.1", "@types/request": "2.48.5", - "firebase-admin": "9.4.2", - "firebase-tools": "9.1.0", + "firebase-admin": "9.7.0", + "firebase-tools": "9.10.1", "firebase-functions": "3.13.0", "rollup": "2.35.1", "rollup-plugin-typescript2": "0.29.0" }, "peerDependencies": { "@google-cloud/firestore": "^4.2.0", - "firebase-admin": "^9.0.0" + "firebase-admin": "^9.7.0" }, "repository": { "directory": "packages/rules-unit-testing", diff --git a/packages/rules-unit-testing/src/api/index.ts b/packages/rules-unit-testing/src/api/index.ts index 818505f5c0a..1fdf3800d61 100644 --- a/packages/rules-unit-testing/src/api/index.ts +++ b/packages/rules-unit-testing/src/api/index.ts @@ -16,6 +16,11 @@ */ import firebase from 'firebase'; +import 'firebase/database'; +import 'firebase/firestore'; +import 'firebase/storage'; + +import type { app } from 'firebase-admin'; import { _FirebaseApp } from '@firebase/app-types/private'; import { FirebaseAuthInternal } from '@firebase/auth-interop-types'; import * as request from 'request'; @@ -23,19 +28,25 @@ import { base64 } from '@firebase/util'; import { setLogLevel, LogLevel } from '@firebase/logger'; import { Component, ComponentType } from '@firebase/component'; -const { firestore, database } = firebase; -export { firestore, database }; +const { firestore, database, storage } = firebase; +export { firestore, database, storage }; /** If this environment variable is set, use it for the database emulator's address. */ const DATABASE_ADDRESS_ENV: string = 'FIREBASE_DATABASE_EMULATOR_HOST'; /** The default address for the local database emulator. */ const DATABASE_ADDRESS_DEFAULT: string = 'localhost:9000'; -/** If any of environment variable is set, use it for the Firestore emulator. */ +/** If this environment variable is set, use it for the Firestore emulator. */ const FIRESTORE_ADDRESS_ENV: string = 'FIRESTORE_EMULATOR_HOST'; /** The default address for the local Firestore emulator. */ const FIRESTORE_ADDRESS_DEFAULT: string = 'localhost:8080'; +/** If this environment variable is set, use it for the Storage emulator. */ +const FIREBASE_STORAGE_ADDRESS_ENV: string = 'FIREBASE_STORAGE_EMULATOR_HOST'; +const CLOUD_STORAGE_ADDRESS_ENV: string = 'STORAGE_EMULATOR_HOST'; +/** The default address for the local Firestore emulator. */ +const STORAGE_ADDRESS_DEFAULT: string = 'localhost:9199'; + /** Environment variable to locate the Emulator Hub */ const HUB_HOST_ENV: string = 'FIREBASE_EMULATOR_HUB'; /** The default address for the Emulator Hub */ @@ -47,6 +58,9 @@ let _databaseHost: string | undefined = undefined; /** The actual address for the Firestore emulator */ let _firestoreHost: string | undefined = undefined; +/** The actual address for the Storage emulator */ +let _storageHost: string | undefined = undefined; + /** The actual address for the Emulator Hub */ let _hubHost: string | undefined = undefined; @@ -133,6 +147,10 @@ export type FirebaseEmulatorOptions = { host: string; port: number; }; + storage?: { + host: string; + port: number; + }; hub?: { host: string; port: number; @@ -193,6 +211,7 @@ export function apps(): firebase.app.App[] { export type AppOptions = { databaseName?: string; projectId?: string; + storageBucket?: string; auth?: TokenOptions; }; /** Construct an App authenticated with options.auth. */ @@ -201,19 +220,29 @@ export function initializeTestApp(options: AppOptions): firebase.app.App { ? createUnsecuredJwt(options.auth, options.projectId) : undefined; - return initializeApp(jwt, options.databaseName, options.projectId); + return initializeApp( + jwt, + options.databaseName, + options.projectId, + options.storageBucket + ); } export type AdminAppOptions = { databaseName?: string; projectId?: string; + storageBucket?: string; }; /** Construct an App authenticated as an admin user. */ -export function initializeAdminApp(options: AdminAppOptions): firebase.app.App { +export function initializeAdminApp(options: AdminAppOptions): app.App { const admin = require('firebase-admin'); - const app = admin.initializeApp( - getAppOptions(options.databaseName, options.projectId), + const app: app.App = admin.initializeApp( + getAppOptions( + options.databaseName, + options.projectId, + options.storageBucket + ), getRandomAppName() ); @@ -234,9 +263,9 @@ export function initializeAdminApp(options: AdminAppOptions): firebase.app.App { * @param options options object. */ export function useEmulators(options: FirebaseEmulatorOptions): void { - if (!(options.database || options.firestore || options.hub)) { + if (!(options.database || options.firestore || options.storage || options.hub)) { throw new Error( - "Argument to useEmulators must contain at least one of 'database', 'firestore', or 'hub'." + "Argument to useEmulators must contain at least one of 'database', 'firestore', 'storage', or 'hub'." ); } @@ -248,6 +277,10 @@ export function useEmulators(options: FirebaseEmulatorOptions): void { _firestoreHost = getAddress(options.firestore.host, options.firestore.port); } + if (options.storage) { + _storageHost = getAddress(options.storage.host, options.storage.port); + } + if (options.hub) { _hubHost = getAddress(options.hub.host, options.hub.port); } @@ -301,6 +334,13 @@ export async function discoverEmulators( }; } + if (data.storage) { + options.storage = { + host: data.storage.host, + port: data.storage.port + }; + } + if (data.hub) { options.hub = { host: data.hub.host, @@ -351,6 +391,27 @@ function getFirestoreHost() { return _firestoreHost; } +function getStorageHost() { + if (!_storageHost) { + const fromEnv = + process.env[FIREBASE_STORAGE_ADDRESS_ENV] || + process.env[CLOUD_STORAGE_ADDRESS_ENV]; + if (fromEnv) { + // The STORAGE_EMULATOR_HOST env var is an older Cloud Standard which includes http:// while + // the FIREBASE_STORAGE_EMULATOR_HOST is a newer variable supported beginning in the Admin + // SDK v9.7.0 which does not have the protocol. + _storageHost = fromEnv.replace('http://', ''); + } else { + console.warn( + `Warning: ${FIREBASE_STORAGE_ADDRESS_ENV} not set, using default value ${STORAGE_ADDRESS_DEFAULT}` + ); + _storageHost = STORAGE_ADDRESS_DEFAULT; + } + } + + return _storageHost; +} + function getHubHost() { if (!_hubHost) { const fromEnv = process.env[HUB_HOST_ENV]; @@ -367,34 +428,52 @@ function getHubHost() { return _hubHost; } +function parseHost(host: string): { hostname: string; port: number } { + const withProtocol = host.startsWith("http") ? host : `http://${host}`; + const u = new URL(withProtocol); + return { + hostname: u.hostname, + port: Number.parseInt(u.port, 10) + }; +} + function getRandomAppName(): string { return 'app-' + new Date().getTime() + '-' + Math.random(); } +function getDatabaseUrl(databaseName: string) { + return `http://${getDatabaseHost()}?ns=${databaseName}`; +} + function getAppOptions( databaseName?: string, - projectId?: string + projectId?: string, + storageBucket?: string ): { [key: string]: string } { let appOptions: { [key: string]: string } = {}; if (databaseName) { - appOptions[ - 'databaseURL' - ] = `http://${getDatabaseHost()}?ns=${databaseName}`; + appOptions['databaseURL'] = getDatabaseUrl(databaseName); } + if (projectId) { appOptions['projectId'] = projectId; } + if (storageBucket) { + appOptions['storageBucket'] = storageBucket; + } + return appOptions; } function initializeApp( accessToken?: string, databaseName?: string, - projectId?: string + projectId?: string, + storageBucket?: string ): firebase.app.App { - const appOptions = getAppOptions(databaseName, projectId); + const appOptions = getAppOptions(databaseName, projectId, storageBucket); const app = firebase.initializeApp(appOptions, getRandomAppName()); if (accessToken) { const mockAuthComponent = new Component( @@ -417,6 +496,9 @@ function initializeApp( ); } if (databaseName) { + const { hostname, port } = parseHost(getDatabaseHost()); + app.database().useEmulator(hostname, port); + // Toggle network connectivity to force a reauthentication attempt. // This mitigates a minor race condition where the client can send the // first database request before authenticating. @@ -424,10 +506,12 @@ function initializeApp( app.database().goOnline(); } if (projectId) { - app.firestore().settings({ - host: getFirestoreHost(), - ssl: false - }); + const { hostname, port } = parseHost(getFirestoreHost()); + app.firestore().useEmulator(hostname, port); + } + if (storageBucket) { + const { hostname, port } = parseHost(getStorageHost()); + app.storage().useEmulator(hostname, port); } /** Mute warnings for the previously-created database and whatever other @@ -498,6 +582,34 @@ export async function loadFirestoreRules( } } +export type LoadStorageRulesOptions = { + rules: string; +}; +export async function loadStorageRules( + options: LoadStorageRulesOptions +): Promise { + if (!options.rules) { + throw new Error('must provide rules to loadStorageRules'); + } + + const resp = await requestPromise(request.put, { + method: 'PUT', + uri: `http://${getStorageHost()}/internal/setRules`, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + rules: { + files: [{ name: 'storage.rules', content: options.rules }] + } + }) + }); + + if (resp.statusCode !== 200) { + throw new Error(resp.body); + } +} + export type ClearFirestoreDataOptions = { projectId: string; }; diff --git a/packages/rules-unit-testing/test/database.test.ts b/packages/rules-unit-testing/test/database.test.ts index 7117f1bc233..98df97332ca 100644 --- a/packages/rules-unit-testing/test/database.test.ts +++ b/packages/rules-unit-testing/test/database.test.ts @@ -165,6 +165,10 @@ describe('Testing Module Tests', function () { host: 'localhost', port: 9003 }, + storage: { + host: 'localhost', + port: 9199 + }, hub: { host: 'localhost', port: 4400 @@ -216,7 +220,24 @@ describe('Testing Module Tests', function () { }); }); - it('initializeAdminApp() has admin access', async function () { + it('initializeAdminApp() has admin access to RTDB', async function () { + await firebase.loadDatabaseRules({ + databaseName: 'foo', + rules: '{ "rules": {".read": false, ".write": false} }' + }); + + const app = firebase.initializeAdminApp({ + projectId: 'foo', + databaseName: 'foo', + storageBucket: 'foo' + }); + + await firebase.assertSucceeds( + app.database().ref().child('/foo/bar').set({ hello: 'world' }) + ); + }); + + it('initializeAdminApp() has admin access to Firestore', async function () { await firebase.loadFirestoreRules({ projectId: 'foo', rules: `service cloud.firestore { @@ -226,22 +247,43 @@ describe('Testing Module Tests', function () { }` }); - await firebase.loadDatabaseRules({ - databaseName: 'foo', - rules: '{ "rules": {".read": false, ".write": false} }' - }); - const app = firebase.initializeAdminApp({ projectId: 'foo', - databaseName: 'foo' + databaseName: 'foo', + storageBucket: 'foo' }); await firebase.assertSucceeds( app.firestore().doc('/foo/bar').set({ hello: 'world' }) ); - await firebase.assertSucceeds( - app.database().ref().child('/foo/bar').set({ hello: 'world' }) - ); + }); + + it('initializeAdminApp() has admin access to storage', async function () { + await firebase.loadStorageRules({ + rules: `rules_version = '2'; + service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if false; + } + } + }` + }); + + const app = firebase.initializeAdminApp({ + projectId: 'foo', + databaseName: 'foo', + storageBucket: 'foo' + }); + + // TODO: This test cannot be enabled without adding credentials to the test environment + // due to an underlying issue with firebase-admin storage. For now we will run it + // locally but not in CI. + if (process.env.CI !== "true") { + await firebase.assertSucceeds( + app.storage().bucket().file('/foo/bar.txt').save('Hello, World!') + ); + } }); it('initializeAdminApp() and initializeTestApp() work together', async function () { @@ -257,12 +299,14 @@ describe('Testing Module Tests', function () { const adminApp = firebase.initializeAdminApp({ projectId: 'foo', - databaseName: 'foo' + databaseName: 'foo', + storageBucket: 'foo' }); const testApp = firebase.initializeTestApp({ projectId: 'foo', - databaseName: 'foo' + databaseName: 'foo', + storageBucket: 'foo' }); // Admin app can write anywhere @@ -375,6 +419,30 @@ describe('Testing Module Tests', function () { }); }); + it('loadStorageRules() succeeds on valid input', async function () { + await firebase.loadStorageRules({ + rules: `rules_version = '2'; + service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if false; + } + } + }` + }); + }); + + it('loadStorageRules() fails on invalid input', async function () { + const p = firebase.loadStorageRules({ + rules: `rules_version = '2'; + service firebase.storage { + banana + }` + }); + + await expect(p).to.eventually.be.rejected; + }); + it('clearFirestoreData() succeeds on valid input', async function () { await firebase.clearFirestoreData({ projectId: 'foo' diff --git a/packages/rules-unit-testing/test/storage.rules b/packages/rules-unit-testing/test/storage.rules new file mode 100644 index 00000000000..2cb8b6ece92 --- /dev/null +++ b/packages/rules-unit-testing/test/storage.rules @@ -0,0 +1,7 @@ +service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if false; + } + } +} diff --git a/packages/rxfire/package.json b/packages/rxfire/package.json index 2a7df503900..daa670c7d79 100644 --- a/packages/rxfire/package.json +++ b/packages/rxfire/package.json @@ -39,7 +39,7 @@ "rxjs": "6.x.x" }, "devDependencies": { - "firebase": "8.4.1", + "firebase": "8.6.1", "rollup": "2.35.1", "@rollup/plugin-commonjs": "17.1.0", "@rollup/plugin-node-resolve": "11.2.0", diff --git a/packages/storage-types/CHANGELOG.md b/packages/storage-types/CHANGELOG.md index 3b330d9ad52..77125c8694a 100644 --- a/packages/storage-types/CHANGELOG.md +++ b/packages/storage-types/CHANGELOG.md @@ -1,5 +1,11 @@ # @firebase/storage-types +## 0.4.1 + +### Patch Changes + +- [`3f370215a`](https://github.com/firebase/firebase-js-sdk/commit/3f370215aa571db6b41b92a7d8a9aaad2ea0ecd0) [#4808](https://github.com/firebase/firebase-js-sdk/pull/4808) (fixes [#4789](https://github.com/firebase/firebase-js-sdk/issues/4789)) - Update peerDependencies + ## 0.4.0 ### Minor Changes diff --git a/packages/storage-types/package.json b/packages/storage-types/package.json index 08a2732ba10..b08c98c78a5 100644 --- a/packages/storage-types/package.json +++ b/packages/storage-types/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/storage-types", - "version": "0.4.0", + "version": "0.4.1", "description": "@firebase/storage Types", "author": "Firebase (https://firebase.google.com/)", "license": "Apache-2.0", diff --git a/packages/storage/CHANGELOG.md b/packages/storage/CHANGELOG.md index 87b9fce0c2e..381e4e4af7a 100644 --- a/packages/storage/CHANGELOG.md +++ b/packages/storage/CHANGELOG.md @@ -1,5 +1,22 @@ #Unreleased +## 0.5.2 + +### Patch Changes + +- Updated dependencies [[`c34ac7a92`](https://github.com/firebase/firebase-js-sdk/commit/c34ac7a92a616915f38d192654db7770d81747ae), [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467)]: + - @firebase/component@0.5.0 + - @firebase/util@1.1.0 + +## 0.5.1 + +### Patch Changes + +- [`364e336a0`](https://github.com/firebase/firebase-js-sdk/commit/364e336a04e419d019846d702cf27144aeb8939e) [#4807](https://github.com/firebase/firebase-js-sdk/pull/4807) - Fix infinite recursion caused by `FirebaseStorageError` message getter. + +- Updated dependencies [[`3f370215a`](https://github.com/firebase/firebase-js-sdk/commit/3f370215aa571db6b41b92a7d8a9aaad2ea0ecd0)]: + - @firebase/storage-types@0.4.1 + ## 0.5.0 ### Minor Changes diff --git a/packages/storage/exp/index.ts b/packages/storage/exp/index.ts index c3027536c5d..830753af555 100644 --- a/packages/storage/exp/index.ts +++ b/packages/storage/exp/index.ts @@ -70,10 +70,12 @@ function factory( ): StorageService { const app = container.getProvider('app-exp').getImmediate(); const authProvider = container.getProvider('auth-internal'); + const appCheckProvider = container.getProvider('app-check-internal'); return new StorageServiceInternal( app, authProvider, + appCheckProvider, new XhrIoPool(), url, SDK_VERSION diff --git a/packages/storage/index.ts b/packages/storage/index.ts index 8e42fb3ad71..122b5961132 100644 --- a/packages/storage/index.ts +++ b/packages/storage/index.ts @@ -49,6 +49,7 @@ function factory( // TODO: This should eventually be 'app-compat' const app = container.getProvider('app').getImmediate(); const authProvider = container.getProvider('auth-internal'); + const appCheckProvider = container.getProvider('app-check-internal'); // TODO: get StorageService instance from component framework instead // of creating a new one. @@ -57,6 +58,7 @@ function factory( new StorageService( app, authProvider, + appCheckProvider, new XhrIoPool(), url, firebase.SDK_VERSION diff --git a/packages/storage/package.json b/packages/storage/package.json index 44e9b4a14e0..d09f52aa14d 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/storage", - "version": "0.5.0", + "version": "0.5.2", "description": "", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", @@ -37,9 +37,9 @@ }, "license": "Apache-2.0", "dependencies": { - "@firebase/storage-types": "0.4.0", - "@firebase/util": "1.0.0", - "@firebase/component": "0.4.1", + "@firebase/storage-types": "0.4.1", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.0", "tslib": "^2.1.0" }, "peerDependencies": { @@ -47,8 +47,8 @@ "@firebase/app-types": "0.x" }, "devDependencies": { - "@firebase/app": "0.6.20", - "@firebase/auth": "0.16.4", + "@firebase/app": "0.6.22", + "@firebase/auth": "0.16.5", "rollup": "2.35.1", "@rollup/plugin-json": "4.1.0", "rollup-plugin-typescript2": "0.29.0", diff --git a/packages/storage/src/implementation/error.ts b/packages/storage/src/implementation/error.ts index c0c20d1c6eb..41a29fd5dff 100644 --- a/packages/storage/src/implementation/error.ts +++ b/packages/storage/src/implementation/error.ts @@ -22,6 +22,7 @@ import { CONFIG_STORAGE_BUCKET_KEY } from './constants'; * @public */ export class FirebaseStorageError extends FirebaseError { + private readonly _baseMessage: string; /** * Stores custom error data unque to FirebaseStorageError. */ @@ -37,6 +38,7 @@ export class FirebaseStorageError extends FirebaseError { prependCode(code), `Firebase Storage: ${message} (${prependCode(code)})` ); + this._baseMessage = this.message; // Without this, `instanceof FirebaseStorageError`, in tests for example, // returns false. Object.setPrototypeOf(this, FirebaseStorageError.prototype); @@ -49,17 +51,6 @@ export class FirebaseStorageError extends FirebaseError { return prependCode(code) === this.code; } - /** - * Error message including serverResponse if available. - */ - get message(): string { - if (this.customData.serverResponse) { - return `${this.message}\n${this.customData.serverResponse}`; - } else { - return this.message; - } - } - /** * Optional response message that was added by the server. */ @@ -69,6 +60,11 @@ export class FirebaseStorageError extends FirebaseError { set serverResponse(serverResponse: string | null) { this.customData.serverResponse = serverResponse; + if (this.customData.serverResponse) { + this.message = `${this._baseMessage}\n${this.customData.serverResponse}`; + } else { + this.message = this._baseMessage; + } } } @@ -87,6 +83,7 @@ export const enum StorageErrorCode { QUOTA_EXCEEDED = 'quota-exceeded', UNAUTHENTICATED = 'unauthenticated', UNAUTHORIZED = 'unauthorized', + UNAUTHORIZED_APP = 'unauthorized-app', RETRY_LIMIT_EXCEEDED = 'retry-limit-exceeded', INVALID_CHECKSUM = 'invalid-checksum', CANCELED = 'canceled', @@ -156,6 +153,13 @@ export function unauthenticated(): FirebaseStorageError { return new FirebaseStorageError(StorageErrorCode.UNAUTHENTICATED, message); } +export function unauthorizedApp(): FirebaseStorageError { + return new FirebaseStorageError( + StorageErrorCode.UNAUTHORIZED_APP, + 'This app does not have permission to access Firebase Storage on this project.' + ); +} + export function unauthorized(path: string): FirebaseStorageError { return new FirebaseStorageError( StorageErrorCode.UNAUTHORIZED, diff --git a/packages/storage/src/implementation/request.ts b/packages/storage/src/implementation/request.ts index 17f1b63c0c9..49b108d52de 100644 --- a/packages/storage/src/implementation/request.ts +++ b/packages/storage/src/implementation/request.ts @@ -280,10 +280,20 @@ export function addGmpidHeader_(headers: Headers, appId: string | null): void { } } +export function addAppCheckHeader_( + headers: Headers, + appCheckToken: string | null +): void { + if (appCheckToken !== null) { + headers['X-Firebase-AppCheck'] = appCheckToken; + } +} + export function makeRequest( requestInfo: RequestInfo, appId: string | null, authToken: string | null, + appCheckToken: string | null, pool: XhrIoPool, firebaseVersion?: string ): Request { @@ -293,6 +303,7 @@ export function makeRequest( addGmpidHeader_(headers, appId); addAuthHeader_(headers, authToken); addVersionHeader_(headers, firebaseVersion); + addAppCheckHeader_(headers, appCheckToken); return new NetworkRequest( url, requestInfo.method, diff --git a/packages/storage/src/implementation/requests.ts b/packages/storage/src/implementation/requests.ts index 860927fcb9e..7a4954cd8d4 100644 --- a/packages/storage/src/implementation/requests.ts +++ b/packages/storage/src/implementation/requests.ts @@ -30,7 +30,8 @@ import { unauthorized, objectNotFound, serverFileWrongSize, - unknown + unknown, + unauthorizedApp } from './error'; import { Location } from './location'; import { @@ -104,7 +105,15 @@ export function sharedErrorHandler( ): FirebaseStorageError { let newErr; if (xhr.getStatus() === 401) { - newErr = unauthenticated(); + if ( + // This exact message string is the only consistent part of the + // server's error response that identifies it as an App Check error. + xhr.getResponseText().includes('Firebase App Check token is invalid') + ) { + newErr = unauthorizedApp(); + } else { + newErr = unauthenticated(); + } } else { if (xhr.getStatus() === 402) { newErr = quotaExceeded(location.bucket); diff --git a/packages/storage/src/reference.ts b/packages/storage/src/reference.ts index 7431146bba3..7624194feaa 100644 --- a/packages/storage/src/reference.ts +++ b/packages/storage/src/reference.ts @@ -152,19 +152,16 @@ export function uploadBytes( metadata?: Metadata ): Promise { ref._throwIfRoot('uploadBytes'); + const requestInfo = multipartUpload( + ref.storage, + ref._location, + getMappings(), + new FbsBlob(data, true), + metadata + ); return ref.storage - ._getAuthToken() - .then(authToken => { - const requestInfo = multipartUpload( - ref.storage, - ref._location, - getMappings(), - new FbsBlob(data, true), - metadata - ); - const multipartRequest = ref.storage._makeRequest(requestInfo, authToken); - return multipartRequest.getPromise(); - }) + .makeRequestWithTokens(requestInfo) + .then(request => request.getPromise()) .then(finalMetadata => { return { metadata: finalMetadata, @@ -302,7 +299,6 @@ export async function list( ); } } - const authToken = await ref.storage._getAuthToken(); const op = options || {}; const requestInfo = requestsList( ref.storage, @@ -311,7 +307,7 @@ export async function list( op.pageToken, op.maxResults ); - return ref.storage._makeRequest(requestInfo, authToken).getPromise(); + return (await ref.storage.makeRequestWithTokens(requestInfo)).getPromise(); } /** @@ -323,13 +319,12 @@ export async function list( */ export async function getMetadata(ref: Reference): Promise { ref._throwIfRoot('getMetadata'); - const authToken = await ref.storage._getAuthToken(); const requestInfo = requestsGetMetadata( ref.storage, ref._location, getMappings() ); - return ref.storage._makeRequest(requestInfo, authToken).getPromise(); + return (await ref.storage.makeRequestWithTokens(requestInfo)).getPromise(); } /** @@ -348,14 +343,13 @@ export async function updateMetadata( metadata: Partial ): Promise { ref._throwIfRoot('updateMetadata'); - const authToken = await ref.storage._getAuthToken(); const requestInfo = requestsUpdateMetadata( ref.storage, ref._location, metadata, getMappings() ); - return ref.storage._makeRequest(requestInfo, authToken).getPromise(); + return (await ref.storage.makeRequestWithTokens(requestInfo)).getPromise(); } /** @@ -366,14 +360,12 @@ export async function updateMetadata( */ export async function getDownloadURL(ref: Reference): Promise { ref._throwIfRoot('getDownloadURL'); - const authToken = await ref.storage._getAuthToken(); const requestInfo = requestsGetDownloadUrl( ref.storage, ref._location, getMappings() ); - return ref.storage - ._makeRequest(requestInfo, authToken) + return (await ref.storage.makeRequestWithTokens(requestInfo)) .getPromise() .then(url => { if (url === null) { @@ -391,9 +383,8 @@ export async function getDownloadURL(ref: Reference): Promise { */ export async function deleteObject(ref: Reference): Promise { ref._throwIfRoot('deleteObject'); - const authToken = await ref.storage._getAuthToken(); const requestInfo = requestsDeleteObject(ref.storage, ref._location); - return ref.storage._makeRequest(requestInfo, authToken).getPromise(); + return (await ref.storage.makeRequestWithTokens(requestInfo)).getPromise(); } /** diff --git a/packages/storage/src/service.ts b/packages/storage/src/service.ts index b966a0a9a91..b54f2eae235 100644 --- a/packages/storage/src/service.ts +++ b/packages/storage/src/service.ts @@ -23,6 +23,7 @@ import { XhrIoPool } from './implementation/xhriopool'; import { Reference, _getChild } from './reference'; import { Provider } from '@firebase/component'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { FirebaseApp, FirebaseOptions, @@ -166,6 +167,13 @@ export class StorageService implements _FirebaseService { */ readonly app: FirebaseApp, readonly _authProvider: Provider, + /** + * @internal + */ + readonly _appCheckProvider: Provider, + /** + * @internal + */ readonly _pool: XhrIoPool, readonly _url?: string, readonly _firebaseVersion?: string @@ -244,6 +252,19 @@ export class StorageService implements _FirebaseService { return null; } + async _getAppCheckToken(): Promise { + const appCheck = this._appCheckProvider.getImmediate({ optional: true }); + if (appCheck) { + const result = await appCheck.getToken(); + // TODO: What do we want to do if there is an error getting the token? + // Context: appCheck.getToken() will never throw even if an error happened. In the error case, a dummy token will be + // returned along with an error field describing the error. In general, we shouldn't care about the error condition and just use + // the token (actual or dummy) to send requests. + return result.token; + } + return null; + } + /** * Stop running requests and prevent more from being created. */ @@ -268,13 +289,15 @@ export class StorageService implements _FirebaseService { */ _makeRequest( requestInfo: RequestInfo, - authToken: string | null + authToken: string | null, + appCheckToken: string | null ): Request { if (!this._deleted) { const request = makeRequest( requestInfo, this._appId, authToken, + appCheckToken, this._pool, this._firebaseVersion ); @@ -289,4 +312,15 @@ export class StorageService implements _FirebaseService { return new FailRequest(appDeleted()); } } + + async makeRequestWithTokens( + requestInfo: RequestInfo + ): Promise> { + const [authToken, appCheckToken] = await Promise.all([ + this._getAuthToken(), + this._getAppCheckToken() + ]); + + return this._makeRequest(requestInfo, authToken, appCheckToken); + } } diff --git a/packages/storage/src/task.ts b/packages/storage/src/task.ts index ebe8f88ef40..e7edbc4e076 100644 --- a/packages/storage/src/task.ts +++ b/packages/storage/src/task.ts @@ -172,12 +172,17 @@ export class UploadTask { } } - private _resolveToken(callback: (p1: string | null) => void): void { + private _resolveToken( + callback: (authToken: string | null, appCheckToken: string | null) => void + ): void { // eslint-disable-next-line @typescript-eslint/no-floating-promises - this._ref.storage._getAuthToken().then(authToken => { + Promise.all([ + this._ref.storage._getAuthToken(), + this._ref.storage._getAppCheckToken() + ]).then(([authToken, appCheckToken]) => { switch (this._state) { case InternalTaskState.RUNNING: - callback(authToken); + callback(authToken, appCheckToken); break; case InternalTaskState.CANCELING: this._transition(InternalTaskState.CANCELED); @@ -193,7 +198,7 @@ export class UploadTask { // TODO(andysoto): assert false private _createResumable(): void { - this._resolveToken(authToken => { + this._resolveToken((authToken, appCheckToken) => { const requestInfo = createResumableUpload( this._ref.storage, this._ref._location, @@ -203,7 +208,8 @@ export class UploadTask { ); const createRequest = this._ref.storage._makeRequest( requestInfo, - authToken + authToken, + appCheckToken ); this._request = createRequest; createRequest.getPromise().then((url: string) => { @@ -218,7 +224,7 @@ export class UploadTask { private _fetchStatus(): void { // TODO(andysoto): assert(this.uploadUrl_ !== null); const url = this._uploadUrl as string; - this._resolveToken(authToken => { + this._resolveToken((authToken, appCheckToken) => { const requestInfo = getResumableUploadStatus( this._ref.storage, this._ref._location, @@ -227,7 +233,8 @@ export class UploadTask { ); const statusRequest = this._ref.storage._makeRequest( requestInfo, - authToken + authToken, + appCheckToken ); this._request = statusRequest; statusRequest.getPromise().then(status => { @@ -252,7 +259,7 @@ export class UploadTask { // TODO(andysoto): assert(this.uploadUrl_ !== null); const url = this._uploadUrl as string; - this._resolveToken(authToken => { + this._resolveToken((authToken, appCheckToken) => { let requestInfo; try { requestInfo = continueResumableUpload( @@ -272,7 +279,8 @@ export class UploadTask { } const uploadRequest = this._ref.storage._makeRequest( requestInfo, - authToken + authToken, + appCheckToken ); this._request = uploadRequest; uploadRequest.getPromise().then((newStatus: ResumableUploadStatus) => { @@ -299,7 +307,7 @@ export class UploadTask { } private _fetchMetadata(): void { - this._resolveToken(authToken => { + this._resolveToken((authToken, appCheckToken) => { const requestInfo = getMetadata( this._ref.storage, this._ref._location, @@ -307,7 +315,8 @@ export class UploadTask { ); const metadataRequest = this._ref.storage._makeRequest( requestInfo, - authToken + authToken, + appCheckToken ); this._request = metadataRequest; metadataRequest.getPromise().then(metadata => { @@ -319,7 +328,7 @@ export class UploadTask { } private _oneShotUpload(): void { - this._resolveToken(authToken => { + this._resolveToken((authToken, appCheckToken) => { const requestInfo = multipartUpload( this._ref.storage, this._ref._location, @@ -329,7 +338,8 @@ export class UploadTask { ); const multipartRequest = this._ref.storage._makeRequest( requestInfo, - authToken + authToken, + appCheckToken ); this._request = multipartRequest; multipartRequest.getPromise().then(metadata => { diff --git a/packages/storage/test/unit/reference.compat.test.ts b/packages/storage/test/unit/reference.compat.test.ts index 46e07c2544f..ccb270109a3 100644 --- a/packages/storage/test/unit/reference.compat.test.ts +++ b/packages/storage/test/unit/reference.compat.test.ts @@ -28,16 +28,23 @@ import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; import { StorageService } from '../../src/service'; import { Reference } from '../../src/reference'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; /* eslint-disable @typescript-eslint/no-floating-promises */ function makeFakeService( app: FirebaseApp, authProvider: Provider, + appCheckProvider: Provider, sendHook: SendHook ): StorageServiceCompat { const storageServiceCompat: StorageServiceCompat = new StorageServiceCompat( app, - new StorageService(app, authProvider, testShared.makePool(sendHook)) + new StorageService( + app, + authProvider, + appCheckProvider, + testShared.makePool(sendHook) + ) ); return storageServiceCompat; } @@ -46,6 +53,7 @@ function makeStorage(url: string): ReferenceCompat { const service = new StorageService( {} as FirebaseApp, testShared.emptyAuthProvider, + testShared.fakeAppCheckTokenProvider, testShared.makePool(null) ); const storageServiceCompat: StorageServiceCompat = new StorageServiceCompat( @@ -195,6 +203,7 @@ describe('Firebase Storage > Reference', () => { const service = makeFakeService( testShared.fakeApp, testShared.emptyAuthProvider, + testShared.fakeAppCheckTokenProvider, newSend ); const ref = service.refFromURL('gs://test-bucket'); @@ -220,6 +229,7 @@ describe('Firebase Storage > Reference', () => { const service = makeFakeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, newSend ); const ref = service.refFromURL('gs://test-bucket'); diff --git a/packages/storage/test/unit/reference.exp.test.ts b/packages/storage/test/unit/reference.exp.test.ts index dc8b8b2ebfb..db4350e83d6 100644 --- a/packages/storage/test/unit/reference.exp.test.ts +++ b/packages/storage/test/unit/reference.exp.test.ts @@ -36,21 +36,29 @@ import { SendHook, TestingXhrIo } from './xhrio'; import { DEFAULT_HOST } from '../../src/implementation/constants'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { fakeServerHandler, storageServiceWithHandler } from './testshared'; /* eslint-disable @typescript-eslint/no-floating-promises */ function makeFakeService( app: FirebaseApp, authProvider: Provider, + appCheckProvider: Provider, sendHook: SendHook ): StorageService { - return new StorageService(app, authProvider, testShared.makePool(sendHook)); + return new StorageService( + app, + authProvider, + appCheckProvider, + testShared.makePool(sendHook) + ); } function makeStorage(url: string): Reference { const service = new StorageService( {} as FirebaseApp, testShared.emptyAuthProvider, + testShared.fakeAppCheckTokenProvider, testShared.makePool(null) ); return new Reference(service, url); @@ -76,6 +84,7 @@ function withFakeSend( const service = makeFakeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, newSend ); return ref(service, 'gs://test-bucket'); @@ -221,6 +230,7 @@ describe('Firebase Storage > Reference', () => { const service = makeFakeService( testShared.fakeApp, testShared.emptyAuthProvider, + testShared.fakeAppCheckTokenProvider, newSend ); const reference = ref(service, 'gs://test-bucket'); @@ -246,6 +256,7 @@ describe('Firebase Storage > Reference', () => { const service = makeFakeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, newSend ); const reference = ref(service, 'gs://test-bucket'); diff --git a/packages/storage/test/unit/request.test.ts b/packages/storage/test/unit/request.test.ts index 3a6bbf43a7b..cb1f6c776a3 100644 --- a/packages/storage/test/unit/request.test.ts +++ b/packages/storage/test/unit/request.test.ts @@ -63,6 +63,7 @@ describe('Firebase Storage > Request', () => { requestInfo, null, null, + null, makePool(spiedSend), TEST_VERSION ) @@ -107,7 +108,7 @@ describe('Firebase Storage > Request', () => { requestInfo.urlParams[p1] = v1; requestInfo.urlParams[p2] = v2; requestInfo.body = 'thisistherequestbody'; - return makeRequest(requestInfo, null, null, makePool(spiedSend)) + return makeRequest(requestInfo, null, null, null, makePool(spiedSend)) .getPromise() .then( () => { @@ -150,7 +151,7 @@ describe('Firebase Storage > Request', () => { timeout ); - return makeRequest(requestInfo, null, null, makePool(newSend)) + return makeRequest(requestInfo, null, null, null, makePool(newSend)) .getPromise() .then( () => { @@ -172,7 +173,7 @@ describe('Firebase Storage > Request', () => { handler, timeout ); - const request = makeRequest(requestInfo, null, null, makePool(null)); + const request = makeRequest(requestInfo, null, null, null, makePool(null)); const promise = request.getPromise().then( () => { assert.fail('Succeeded when handler gave error'); @@ -203,6 +204,7 @@ describe('Firebase Storage > Request', () => { requestInfo, /* appId= */ null, authToken, + null, makePool(spiedSend), TEST_VERSION ); @@ -243,6 +245,7 @@ describe('Firebase Storage > Request', () => { requestInfo, appId, null, + null, makePool(spiedSend), TEST_VERSION ); @@ -261,4 +264,45 @@ describe('Firebase Storage > Request', () => { } ); }); + + it('sends appcheck token along properly', () => { + const appCheckToken = 'totallyshaddytoken'; + + function newSend(xhrio: TestingXhrIo): void { + xhrio.simulateResponse(200, '', {}); + } + const spiedSend = sinon.spy(newSend); + + function handler(): boolean { + return true; + } + const requestInfo = new RequestInfo( + 'http://my-url.com/', + 'GET', + handler, + timeout + ); + const request = makeRequest( + requestInfo, + null, + null, + appCheckToken, + makePool(spiedSend), + TEST_VERSION + ); + return request.getPromise().then( + () => { + assert.isTrue(spiedSend.calledOnce); + const args: unknown[] = spiedSend.getCall(0).args; + const expectedHeaders: { [key: string]: string } = { + 'X-Firebase-AppCheck': appCheckToken + }; + expectedHeaders[versionHeaderName] = versionHeaderValue; + assert.deepEqual(args[4], expectedHeaders); + }, + () => { + assert.fail('Request failed unexpectedly'); + } + ); + }); }); diff --git a/packages/storage/test/unit/requests.test.ts b/packages/storage/test/unit/requests.test.ts index f12d1479d9f..1d79e094893 100644 --- a/packages/storage/test/unit/requests.test.ts +++ b/packages/storage/test/unit/requests.test.ts @@ -43,7 +43,8 @@ import { StorageService } from '../../src/service'; import { assertObjectIncludes, fakeXhrIo, - fakeAuthProvider + fakeAuthProvider, + fakeAppCheckTokenProvider } from './testshared'; import { DEFAULT_HOST, @@ -80,6 +81,7 @@ describe('Firebase Storage > Requests', () => { const storageService = new StorageService( mockApp, fakeAuthProvider, + fakeAppCheckTokenProvider, new XhrIoPool() ); diff --git a/packages/storage/test/unit/service.compat.test.ts b/packages/storage/test/unit/service.compat.test.ts index 9a79318cc2f..fea9772c470 100644 --- a/packages/storage/test/unit/service.compat.test.ts +++ b/packages/storage/test/unit/service.compat.test.ts @@ -25,6 +25,7 @@ import { StorageService } from '../../src/service'; import { FirebaseApp } from '@firebase/app-types'; import { Provider } from '@firebase/component'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { TestingXhrIo } from './xhrio'; import { Headers } from '../../src/implementation/xhrio'; @@ -40,12 +41,13 @@ function makeGsUrl(child: string = ''): string { function makeService( app: FirebaseApp, authProvider: Provider, + appCheckProvider: Provider, pool: XhrIoPool, url?: string ): StorageServiceCompat { const storageServiceCompat: StorageServiceCompat = new StorageServiceCompat( app, - new StorageService(app, authProvider, pool, url) + new StorageService(app, authProvider, appCheckProvider, pool, url) ); return storageServiceCompat; } @@ -55,6 +57,7 @@ describe('Firebase Storage > Service', () => { const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); it('Root refs point to the right place', () => { @@ -89,6 +92,7 @@ describe('Firebase Storage > Service', () => { const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, 'gs://foo-bar.appspot.com' ); @@ -99,6 +103,7 @@ describe('Firebase Storage > Service', () => { const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, `http://${DEFAULT_HOST}/v1/b/foo-bar.appspot.com/o` ); @@ -109,6 +114,7 @@ describe('Firebase Storage > Service', () => { const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, `https://${DEFAULT_HOST}/v1/b/foo-bar.appspot.com/o` ); @@ -120,6 +126,7 @@ describe('Firebase Storage > Service', () => { const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, 'foo-bar.appspot.com' ); @@ -130,6 +137,7 @@ describe('Firebase Storage > Service', () => { const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, 'foo-bar.appspot.com' ); @@ -141,6 +149,7 @@ describe('Firebase Storage > Service', () => { makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, 'gs://bucket/object/' ); @@ -153,6 +162,7 @@ describe('Firebase Storage > Service', () => { const service = makeService( fakeAppGs, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); expect(service.ref().toString()).to.equal('gs://mybucket/'); @@ -161,13 +171,19 @@ describe('Firebase Storage > Service', () => { const service = makeService( fakeAppGsEndingSlash, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); expect(service.ref().toString()).to.equal('gs://mybucket/'); }); it('Throws when config bucket is gs:// with an object path', () => { testShared.assertThrows(() => { - makeService(fakeAppInvalidGs, testShared.fakeAuthProvider, xhrIoPool); + makeService( + fakeAppInvalidGs, + testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, + xhrIoPool + ); }, 'storage/invalid-default-bucket'); }); }); @@ -189,6 +205,7 @@ describe('Firebase Storage > Service', () => { const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, testShared.makePool(newSend) ); service.useEmulator('test.host.org', 1234); @@ -200,6 +217,7 @@ describe('Firebase Storage > Service', () => { const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); it('Works with gs:// URLs', () => { @@ -258,6 +276,7 @@ GOOG4-RSA-SHA256` const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); describe('ref', () => { @@ -307,6 +326,7 @@ GOOG4-RSA-SHA256` const service = makeService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); it('In-flight requests are canceled when the service is deleted', async () => { diff --git a/packages/storage/test/unit/service.exp.test.ts b/packages/storage/test/unit/service.exp.test.ts index 6f4d0a3d2f2..1ba3838efe4 100644 --- a/packages/storage/test/unit/service.exp.test.ts +++ b/packages/storage/test/unit/service.exp.test.ts @@ -46,6 +46,7 @@ describe('Firebase Storage > Service', () => { const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); it('Root refs point to the right place', () => { @@ -62,6 +63,7 @@ describe('Firebase Storage > Service', () => { const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, 'gs://foo-bar.appspot.com' ); @@ -72,6 +74,7 @@ describe('Firebase Storage > Service', () => { const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, `http://${DEFAULT_HOST}/v1/b/foo-bar.appspot.com/o` ); @@ -82,6 +85,7 @@ describe('Firebase Storage > Service', () => { const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, `https://${DEFAULT_HOST}/v1/b/foo-bar.appspot.com/o` ); @@ -93,6 +97,7 @@ describe('Firebase Storage > Service', () => { const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, 'foo-bar.appspot.com' ); @@ -103,6 +108,7 @@ describe('Firebase Storage > Service', () => { const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, 'foo-bar.appspot.com' ); @@ -116,6 +122,7 @@ describe('Firebase Storage > Service', () => { new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool, 'gs://bucket/object/' ); @@ -128,6 +135,7 @@ describe('Firebase Storage > Service', () => { const service = new StorageService( fakeAppGs, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); expect(ref(service)?.toString()).to.equal('gs://mybucket/'); @@ -136,6 +144,7 @@ describe('Firebase Storage > Service', () => { const service = new StorageService( fakeAppGsEndingSlash, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); expect(ref(service)?.toString()).to.equal('gs://mybucket/'); @@ -145,6 +154,7 @@ describe('Firebase Storage > Service', () => { new StorageService( fakeAppInvalidGs, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); }, 'storage/invalid-default-bucket'); @@ -154,6 +164,7 @@ describe('Firebase Storage > Service', () => { const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); it('Works with gs:// URLs', () => { @@ -238,6 +249,7 @@ GOOG4-RSA-SHA256` const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, testShared.makePool(newSend) ); useStorageEmulator(service, 'test.host.org', 1234); @@ -249,6 +261,7 @@ GOOG4-RSA-SHA256` const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); it('Works with non URL paths', () => { @@ -264,6 +277,7 @@ GOOG4-RSA-SHA256` const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); const reference = new Reference(service, testLocation); @@ -307,6 +321,7 @@ GOOG4-RSA-SHA256` const service = new StorageService( testShared.fakeApp, testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, xhrIoPool ); it('In-flight requests are canceled when the service is deleted', async () => { diff --git a/packages/storage/test/unit/testshared.ts b/packages/storage/test/unit/testshared.ts index 8ad6369e354..acfeeca7944 100644 --- a/packages/storage/test/unit/testshared.ts +++ b/packages/storage/test/unit/testshared.ts @@ -32,10 +32,12 @@ import { Component, ComponentType } from '@firebase/component'; +import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { StorageService } from '../../src/service'; import { Metadata } from '../../src/metadata'; export const authToken = 'totally-legit-auth-token'; +export const appCheckToken = 'totally-shady-token'; export const bucket = 'mybucket'; export const fakeApp = makeFakeApp(); export const fakeAuthProvider = makeFakeAuthProvider({ @@ -45,6 +47,9 @@ export const emptyAuthProvider = new Provider( 'auth-internal', new ComponentContainer('storage-container') ); +export const fakeAppCheckTokenProvider = makeFakeAppCheckProvider({ + token: appCheckToken +}); export function makeFakeApp(bucketArg?: string): FirebaseApp { const app: any = {}; @@ -79,6 +84,28 @@ export function makeFakeAuthProvider(token: { return provider as Provider; } +export function makeFakeAppCheckProvider(tokenResult: { + token: string; +}): Provider { + const provider = new Provider( + 'app-check-internal', + new ComponentContainer('storage-container') + ); + provider.setComponent( + new Component( + 'app-check-internal', + () => { + return { + getToken: () => Promise.resolve(tokenResult) + } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + }, + ComponentType.PRIVATE + ) + ); + + return provider as Provider; +} + export function makePool(sendHook: SendHook | null): XhrIoPool { const pool: any = { createXhrIo() { @@ -199,6 +226,7 @@ export function storageServiceWithHandler( return new StorageService( {} as FirebaseApp, emptyAuthProvider, + fakeAppCheckTokenProvider, makePool(newSend) ); } diff --git a/packages/template/package.json b/packages/template/package.json index 185970f394f..8996113c8b6 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -33,7 +33,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@firebase/app": "0.6.20", + "@firebase/app": "0.6.22", "rollup": "2.35.1", "rollup-plugin-typescript2": "0.29.0", "typescript": "4.2.2" diff --git a/packages/util/CHANGELOG.md b/packages/util/CHANGELOG.md index 554f93e4f1c..7984c34cb01 100644 --- a/packages/util/CHANGELOG.md +++ b/packages/util/CHANGELOG.md @@ -1,5 +1,11 @@ # @firebase/util +## 1.1.0 + +### Minor Changes + +- [`ac4ad08a2`](https://github.com/firebase/firebase-js-sdk/commit/ac4ad08a284397ec966e991dd388bb1fba857467) [#4792](https://github.com/firebase/firebase-js-sdk/pull/4792) - Add mockUserToken support for database emulator. + ## 1.0.0 ### Major Changes diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index a603393d987..8dace3b8e1e 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -25,6 +25,7 @@ export * from './src/crypt'; export * from './src/constants'; export * from './src/deepCopy'; export * from './src/deferred'; +export * from './src/emulator'; export * from './src/environment'; export * from './src/errors'; export * from './src/json'; diff --git a/packages/util/index.ts b/packages/util/index.ts index d2da4426c4a..00d661734b8 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -20,6 +20,7 @@ export * from './src/crypt'; export * from './src/constants'; export * from './src/deepCopy'; export * from './src/deferred'; +export * from './src/emulator'; export * from './src/environment'; export * from './src/errors'; export * from './src/json'; diff --git a/packages/util/package.json b/packages/util/package.json index 78b4973f1dc..f85d73eaea3 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@firebase/util", - "version": "1.0.0", + "version": "1.1.0", "description": "", "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.node.cjs.js", diff --git a/packages/util/src/emulator.ts b/packages/util/src/emulator.ts new file mode 100644 index 00000000000..6f5a9dbacf8 --- /dev/null +++ b/packages/util/src/emulator.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2021 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 { base64 } from './crypt'; + +// Firebase Auth tokens contain snake_case claims following the JWT standard / convention. +/* eslint-disable camelcase */ + +export type FirebaseSignInProvider = + | 'custom' + | 'email' + | 'password' + | 'phone' + | 'anonymous' + | 'google.com' + | 'facebook.com' + | 'github.com' + | 'twitter.com' + | 'microsoft.com' + | 'apple.com'; + +interface FirebaseIdToken { + // Always set to https://securetoken.google.com/PROJECT_ID + iss: string; + + // Always set to PROJECT_ID + aud: string; + + // The user's unique id + sub: string; + + // The token issue time, in seconds since epoch + iat: number; + + // The token expiry time, normally 'iat' + 3600 + exp: number; + + // The user's unique id, must be equal to 'sub' + user_id: string; + + // The time the user authenticated, normally 'iat' + auth_time: number; + + // The sign in provider, only set when the provider is 'anonymous' + provider_id?: 'anonymous'; + + // The user's primary email + email?: string; + + // The user's email verification status + email_verified?: boolean; + + // The user's primary phone number + phone_number?: string; + + // The user's display name + name?: string; + + // The user's profile photo URL + picture?: string; + + // Information on all identities linked to this user + firebase: { + // The primary sign-in provider + sign_in_provider: FirebaseSignInProvider; + + // A map of providers to the user's list of unique identifiers from + // each provider + identities?: { [provider in FirebaseSignInProvider]?: string[] }; + }; + + // Custom claims set by the developer + [claim: string]: unknown; + + uid?: never; // Try to catch a common mistake of "uid" (should be "sub" instead). +} + +export type EmulatorMockTokenOptions = ({ user_id: string } | { sub: string }) & + Partial; + +export function createMockUserToken( + token: EmulatorMockTokenOptions, + projectId?: string +): string { + if (token.uid) { + throw new Error( + 'The "uid" field is no longer supported by mockUserToken. Please use "sub" instead for Firebase Auth User ID.' + ); + } + // Unsecured JWTs use "none" as the algorithm. + const header = { + alg: 'none', + type: 'JWT' + }; + + const project = projectId || 'demo-project'; + const iat = token.iat || 0; + const sub = token.sub || token.user_id; + if (!sub) { + throw new Error("mockUserToken must contain 'sub' or 'user_id' field!"); + } + + const payload: FirebaseIdToken = { + // Set all required fields to decent defaults + iss: `https://securetoken.google.com/${project}`, + aud: project, + iat, + exp: iat + 3600, + auth_time: iat, + sub, + user_id: sub, + firebase: { + sign_in_provider: 'custom', + identities: {} + }, + + // Override with user options + ...token + }; + + // Unsecured JWTs use the empty string as a signature. + const signature = ''; + return [ + base64.encodeString(JSON.stringify(header), /*webSafe=*/ false), + base64.encodeString(JSON.stringify(payload), /*webSafe=*/ false), + signature + ].join('.'); +} diff --git a/packages/util/src/environment.ts b/packages/util/src/environment.ts index 92fdd4478c7..f2ad5306583 100644 --- a/packages/util/src/environment.ts +++ b/packages/util/src/environment.ts @@ -189,3 +189,20 @@ export function areCookiesEnabled(): boolean { } return true; } + +/** + * Polyfill for `globalThis` object. + * @returns the `globalThis` object for the given environment. + */ +export function getGlobal(): typeof globalThis { + if (typeof self !== 'undefined') { + return self; + } + if (typeof window !== 'undefined') { + return window; + } + if (typeof global !== 'undefined') { + return global; + } + throw new Error('Unable to locate global object.'); +} diff --git a/packages/util/test/emulator.test.ts b/packages/util/test/emulator.test.ts new file mode 100644 index 00000000000..2f1122dcc9f --- /dev/null +++ b/packages/util/test/emulator.test.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2021 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 { base64 } from '../src/crypt'; +import { createMockUserToken, EmulatorMockTokenOptions } from '../src/emulator'; + +// Firebase Auth tokens contain snake_case claims following the JWT standard / convention. +/* eslint-disable camelcase */ + +describe('createMockUserToken()', () => { + it('creates a well-formed JWT', () => { + const projectId = 'my-project'; + const options = { user_id: 'alice' }; + + const token = createMockUserToken(options, projectId); + const claims = JSON.parse( + base64.decodeString(token.split('.')[1], /*webSafe=*/ false) + ); + // We add an 'iat' field. + expect(claims).to.deep.equal({ + iss: 'https://securetoken.google.com/' + projectId, + aud: projectId, + iat: 0, + exp: 3600, + auth_time: 0, + sub: 'alice', + user_id: 'alice', + firebase: { + sign_in_provider: 'custom', + identities: {} + } + }); + }); + + it('rejects "uid" field with error', () => { + const options = { uid: 'alice' }; + + expect(() => + createMockUserToken((options as unknown) as EmulatorMockTokenOptions) + ).to.throw( + 'The "uid" field is no longer supported by mockUserToken. Please use "sub" instead for Firebase Auth User ID.' + ); + }); +}); diff --git a/repo-scripts/api-documenter/package.json b/repo-scripts/api-documenter/package.json index 8e702c69ef5..312d6fd8352 100644 --- a/repo-scripts/api-documenter/package.json +++ b/repo-scripts/api-documenter/package.json @@ -1,7 +1,6 @@ { "name": "@firebase/api-documenter", "version": "0.1.0", - "private": true, "description": "Read JSON files from api-extractor, generate documentation pages", "repository": { "directory": "repo-scripts/documenter", @@ -14,9 +13,12 @@ "test": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.ts --config ../../config/mocharc.node.js" }, "bin": { - "api-documenter": "./bin/api-documenter" + "api-documenter-fire": "./dist/start.js" }, - "main": "lib/index.js", + "files": [ + "dist" + ], + "main": "dist/index.js", "typings": "dist/rollup.d.ts", "dependencies": { "api-extractor-model-me": "0.1.1", @@ -24,7 +26,8 @@ "@rushstack/node-core-library": "3.36.0", "@rushstack/ts-command-line": "4.7.8", "colors": "~1.2.1", - "resolve": "~1.17.0" + "resolve": "~1.17.0", + "tslib": "^2.1.0" }, "devDependencies": { "@types/resolve": "1.17.1", diff --git a/repo-scripts/api-documenter/src/cli/BaseAction.ts b/repo-scripts/api-documenter/src/cli/BaseAction.ts index 8dfbc40770e..0bed34178d3 100644 --- a/repo-scripts/api-documenter/src/cli/BaseAction.ts +++ b/repo-scripts/api-documenter/src/cli/BaseAction.ts @@ -24,6 +24,7 @@ import colors from 'colors'; import { CommandLineAction, + CommandLineFlagParameter, CommandLineStringParameter } from '@rushstack/ts-command-line'; import { FileSystem } from '@rushstack/node-core-library'; @@ -39,11 +40,13 @@ export interface IBuildApiModelResult { apiModel: ApiModel; inputFolder: string; outputFolder: string; + addFileNameSuffix: boolean; } export abstract class BaseAction extends CommandLineAction { private _inputFolderParameter!: CommandLineStringParameter; private _outputFolderParameter!: CommandLineStringParameter; + private _fileNameSuffixParameter!: CommandLineFlagParameter; protected onDefineParameters(): void { // override @@ -65,6 +68,17 @@ export abstract class BaseAction extends CommandLineAction { ` ANY EXISTING CONTENTS WILL BE DELETED!` + ` If omitted, the default is "./${this.actionName}"` }); + + this._fileNameSuffixParameter = this.defineFlagParameter({ + parameterLongName: '--name-suffix', + parameterShortName: '-s', + description: + `Add suffix to interface and class names in the file path.` + + `For example, packageA.myinterface_i.md for MyInterface interface, ` + + `Add packageA.myclass_c.md for MyClass class.` + + `This is to avoid name conflict in case packageA also has, for example, an entry point with the same name in lowercase.` + + `This option is specifically designed for the Admin SDK where such case occurs.` + }); } protected buildApiModel(): IBuildApiModelResult { @@ -79,6 +93,8 @@ export abstract class BaseAction extends CommandLineAction { this._outputFolderParameter.value || `./${this.actionName}`; FileSystem.ensureFolder(outputFolder); + const addFileNameSuffix: boolean = this._fileNameSuffixParameter.value; + for (const filename of FileSystem.readFolder(inputFolder)) { if (filename.match(/\.api\.json$/i)) { console.log(`Reading ${filename}`); @@ -89,7 +105,7 @@ export abstract class BaseAction extends CommandLineAction { this._applyInheritDoc(apiModel, apiModel); - return { apiModel, inputFolder, outputFolder }; + return { apiModel, inputFolder, outputFolder, addFileNameSuffix }; } // TODO: This is a temporary workaround. The long term plan is for API Extractor's DocCommentEnhancer diff --git a/repo-scripts/api-documenter/src/cli/MarkdownAction.ts b/repo-scripts/api-documenter/src/cli/MarkdownAction.ts index 274e0ed48d8..e28e9ec6854 100644 --- a/repo-scripts/api-documenter/src/cli/MarkdownAction.ts +++ b/repo-scripts/api-documenter/src/cli/MarkdownAction.ts @@ -35,12 +35,13 @@ export class MarkdownAction extends BaseAction { protected async onExecute(): Promise { // override - const { apiModel, outputFolder } = this.buildApiModel(); + const { apiModel, outputFolder, addFileNameSuffix } = this.buildApiModel(); const markdownDocumenter: MarkdownDocumenter = new MarkdownDocumenter({ apiModel, documenterConfig: undefined, - outputFolder + outputFolder, + addFileNameSuffix }); markdownDocumenter.generateFiles(); } diff --git a/repo-scripts/api-documenter/src/documenters/MarkdownDocumenter.ts b/repo-scripts/api-documenter/src/documenters/MarkdownDocumenter.ts index 2f79de6e9ad..313ce12e2fd 100644 --- a/repo-scripts/api-documenter/src/documenters/MarkdownDocumenter.ts +++ b/repo-scripts/api-documenter/src/documenters/MarkdownDocumenter.ts @@ -91,6 +91,7 @@ export interface IMarkdownDocumenterOptions { apiModel: ApiModel; documenterConfig: DocumenterConfig | undefined; outputFolder: string; + addFileNameSuffix: boolean; } /** @@ -104,11 +105,13 @@ export class MarkdownDocumenter { private readonly _markdownEmitter: CustomMarkdownEmitter; private readonly _outputFolder: string; private readonly _pluginLoader: PluginLoader; + private readonly _addFileNameSuffix: boolean; public constructor(options: IMarkdownDocumenterOptions) { this._apiModel = options.apiModel; this._documenterConfig = options.documenterConfig; this._outputFolder = options.outputFolder; + this._addFileNameSuffix = options.addFileNameSuffix; this._tsdocConfiguration = CustomDocNodes.configuration; this._markdownEmitter = new CustomMarkdownEmitter(this._apiModel); @@ -123,7 +126,7 @@ export class MarkdownDocumenter { outputFolder: this._outputFolder, documenter: new MarkdownDocumenterAccessor({ getLinkForApiItem: (apiItem: ApiItem) => { - return getLinkForApiItem(apiItem); + return getLinkForApiItem(apiItem, this._addFileNameSuffix); } }) }); @@ -156,7 +159,7 @@ export class MarkdownDocumenter { // write to file const filename: string = path.join( this._outputFolder, - getFilenameForApiItem(apiItem) + getFilenameForApiItem(apiItem, this._addFileNameSuffix) ); const stringBuilder: StringBuilder = new StringBuilder(); @@ -172,7 +175,7 @@ export class MarkdownDocumenter { this._markdownEmitter.emit(stringBuilder, output, { contextApiItem: apiItem, onGetFilenameForApiItem: (apiItemForFilename: ApiItem) => { - return getLinkForApiItem(apiItemForFilename); + return getLinkForApiItem(apiItemForFilename, this._addFileNameSuffix); } }); @@ -326,7 +329,9 @@ export class MarkdownDocumenter { output.push(...createThrowsSection(apiItem, configuration)); break; case ApiItemKind.Namespace: - // this._writeEntryPointOrNamespace(output, apiItem as ApiNamespace); + output.push( + ...this._createEntryPointOrNamespace(apiItem as ApiNamespace) + ); break; case ApiItemKind.Model: output.push(...this._createModelTable(apiItem as ApiModel)); @@ -393,7 +398,11 @@ export class MarkdownDocumenter { case ApiItemKind.Constructor: { constructorsTable.addRow( new DocTableRow({ configuration }, [ - createTitleCell(apiMember, configuration), + createTitleCell( + apiMember, + configuration, + this._addFileNameSuffix + ), createModifiersCell(apiMember, configuration), createDescriptionCell(apiMember, configuration) ]) @@ -407,7 +416,11 @@ export class MarkdownDocumenter { case ApiItemKind.Method: { methodsTable.addRow( new DocTableRow({ configuration }, [ - createTitleCell(apiMember, configuration), + createTitleCell( + apiMember, + configuration, + this._addFileNameSuffix + ), createModifiersCell(apiMember, configuration), createDescriptionCell(apiMember, configuration) ]) @@ -422,7 +435,11 @@ export class MarkdownDocumenter { if ((apiMember as ApiPropertyItem).isEventProperty) { eventsTable.addRow( new DocTableRow({ configuration }, [ - createTitleCell(apiMember, configuration), + createTitleCell( + apiMember, + configuration, + this._addFileNameSuffix + ), createModifiersCell(apiMember, configuration), this._createPropertyTypeCell(apiMember), createDescriptionCell(apiMember, configuration) @@ -435,7 +452,11 @@ export class MarkdownDocumenter { } else { propertiesTable.addRow( new DocTableRow({ configuration }, [ - createTitleCell(apiMember, configuration), + createTitleCell( + apiMember, + configuration, + this._addFileNameSuffix + ), createModifiersCell(apiMember, configuration), this._createPropertyTypeCell(apiMember), createDescriptionCell(apiMember, configuration) @@ -509,7 +530,11 @@ export class MarkdownDocumenter { case ApiItemKind.MethodSignature: { methodsTable.addRow( new DocTableRow({ configuration }, [ - createTitleCell(apiMember, configuration), + createTitleCell( + apiMember, + configuration, + this._addFileNameSuffix + ), createDescriptionCell(apiMember, configuration) ]) ); @@ -523,7 +548,11 @@ export class MarkdownDocumenter { if ((apiMember as ApiPropertyItem).isEventProperty) { eventsTable.addRow( new DocTableRow({ configuration }, [ - createTitleCell(apiMember, configuration), + createTitleCell( + apiMember, + configuration, + this._addFileNameSuffix + ), this._createPropertyTypeCell(apiMember), createDescriptionCell(apiMember, configuration) ]) @@ -534,7 +563,11 @@ export class MarkdownDocumenter { } else { propertiesTable.addRow( new DocTableRow({ configuration }, [ - createTitleCell(apiMember, configuration), + createTitleCell( + apiMember, + configuration, + this._addFileNameSuffix + ), this._createPropertyTypeCell(apiMember), createDescriptionCell(apiMember, configuration) ]) @@ -686,7 +719,10 @@ export class MarkdownDocumenter { configuration, tagName: '@link', linkText: unwrappedTokenText, - urlDestination: getLinkForApiItem(apiItemResult.resolvedApiItem) + urlDestination: getLinkForApiItem( + apiItemResult.resolvedApiItem, + this._addFileNameSuffix + ) }) ); continue; @@ -714,7 +750,7 @@ export class MarkdownDocumenter { for (const apiMember of apiModel.members) { const row: DocTableRow = new DocTableRow({ configuration }, [ - createTitleCell(apiMember, configuration), + createTitleCell(apiMember, configuration, this._addFileNameSuffix), createDescriptionCell(apiMember, configuration) ]); @@ -755,7 +791,11 @@ export class MarkdownDocumenter { for (const entryPoint of apiContainer.entryPoints) { const row: DocTableRow = new DocTableRow({ configuration }, [ - createEntryPointTitleCell(entryPoint, configuration), + createEntryPointTitleCell( + entryPoint, + configuration, + this._addFileNameSuffix + ), createDescriptionCell(entryPoint, configuration) ]); @@ -828,7 +868,7 @@ export class MarkdownDocumenter { for (const apiMember of apiMembers) { const row: DocTableRow = new DocTableRow({ configuration }, [ - createTitleCell(apiMember, configuration), + createTitleCell(apiMember, configuration, this._addFileNameSuffix), createDescriptionCell(apiMember, configuration) ]); diff --git a/repo-scripts/api-documenter/src/documenters/MarkdownDocumenterHelpers.ts b/repo-scripts/api-documenter/src/documenters/MarkdownDocumenterHelpers.ts index 30dec957eeb..2287e329353 100644 --- a/repo-scripts/api-documenter/src/documenters/MarkdownDocumenterHelpers.ts +++ b/repo-scripts/api-documenter/src/documenters/MarkdownDocumenterHelpers.ts @@ -49,13 +49,19 @@ import { DocNoteBox } from '../nodes/DocNoteBox'; import { DocTableRow } from '../nodes/DocTableRow'; import { DocTableCell } from '../nodes/DocTableCell'; -export function getLinkForApiItem(apiItem: ApiItem) { - const fileName = getFilenameForApiItem(apiItem); +export function getLinkForApiItem( + apiItem: ApiItem, + addFileNameSuffix: boolean +) { + const fileName = getFilenameForApiItem(apiItem, addFileNameSuffix); const headingAnchor = getHeadingAnchorForApiItem(apiItem); return `./${fileName}#${headingAnchor}`; } -export function getFilenameForApiItem(apiItem: ApiItem): string { +export function getFilenameForApiItem( + apiItem: ApiItem, + addFileNameSuffix: boolean +): string { if (apiItem.kind === ApiItemKind.Model) { return 'index.md'; } @@ -96,9 +102,16 @@ export function getFilenameForApiItem(apiItem: ApiItem): string { multipleEntryPoints = true; } break; + case ApiItemKind.Namespace: + baseName += '.' + qualifiedName; + if (addFileNameSuffix) { + baseName += '_n'; + } + break; case ApiItemKind.Class: case ApiItemKind.Interface: baseName += '.' + qualifiedName; + break; } } return baseName + '.md'; @@ -224,7 +237,8 @@ export function createExampleSection( export function createTitleCell( apiItem: ApiItem, - configuration: TSDocConfiguration + configuration: TSDocConfiguration, + addFileNameSuffix: boolean ): DocTableCell { return new DocTableCell({ configuration }, [ new DocParagraph({ configuration }, [ @@ -232,7 +246,7 @@ export function createTitleCell( configuration, tagName: '@link', linkText: Utilities.getConciseSignature(apiItem), - urlDestination: getLinkForApiItem(apiItem) + urlDestination: getLinkForApiItem(apiItem, addFileNameSuffix) }) ]) ]); @@ -339,7 +353,8 @@ export function createThrowsSection( export function createEntryPointTitleCell( apiItem: ApiEntryPoint, - configuration: TSDocConfiguration + configuration: TSDocConfiguration, + addFileNameSuffix: boolean ): DocTableCell { return new DocTableCell({ configuration }, [ new DocParagraph({ configuration }, [ @@ -347,7 +362,7 @@ export function createEntryPointTitleCell( configuration, tagName: '@link', linkText: `/${apiItem.displayName}`, - urlDestination: getLinkForApiItem(apiItem) + urlDestination: getLinkForApiItem(apiItem, addFileNameSuffix) }) ]) ]); diff --git a/repo-scripts/api-documenter/src/start.ts b/repo-scripts/api-documenter/src/start.ts index 3ba945be7c7..55d50fb21f2 100644 --- a/repo-scripts/api-documenter/src/start.ts +++ b/repo-scripts/api-documenter/src/start.ts @@ -1,3 +1,5 @@ +#!/usr/bin/env node + /** * @license * Copyright 2020 Google LLC diff --git a/repo-scripts/prune-dts/extract-public-api.ts b/repo-scripts/prune-dts/extract-public-api.ts index 261f009edf7..790bc684120 100644 --- a/repo-scripts/prune-dts/extract-public-api.ts +++ b/repo-scripts/prune-dts/extract-public-api.ts @@ -21,7 +21,7 @@ import * as path from 'path'; import { Extractor, ExtractorConfig } from 'api-extractor-me'; import * as tmp from 'tmp'; -import { pruneDts, removeUnusedImports } from './prune-dts'; +import { addBlankLines, pruneDts, removeUnusedImports } from './prune-dts'; import * as yargs from 'yargs'; /* eslint-disable no-console */ @@ -154,6 +154,8 @@ export async function generateApi( console.log('Generated rollup DTS'); pruneDts(rollupDtsPath, publicDtsPath); console.log('Pruned DTS file'); + await addBlankLines(publicDtsPath); + console.log('Added blank lines after imports'); await removeUnusedImports(publicDtsPath); console.log('Removed unused imports'); diff --git a/repo-scripts/prune-dts/prune-dts.ts b/repo-scripts/prune-dts/prune-dts.ts index 6d1daf8597e..cc94f0adb62 100644 --- a/repo-scripts/prune-dts/prune-dts.ts +++ b/repo-scripts/prune-dts/prune-dts.ts @@ -39,16 +39,47 @@ export function pruneDts(inputLocation: string, outputLocation: string): void { const program = ts.createProgram([inputLocation], compilerOptions, host); const printer: ts.Printer = ts.createPrinter(); const sourceFile = program.getSourceFile(inputLocation)!; + const result: ts.TransformationResult = ts.transform( sourceFile, [dropPrivateApiTransformer.bind(null, program, host)] ); const transformedSourceFile: ts.SourceFile = result.transformed[0]; + let content = printer.printFile(transformedSourceFile); - const content = printer.printFile(transformedSourceFile); fs.writeFileSync(outputLocation, content); } +export async function addBlankLines(outputLocation: string): Promise { + const eslint = new ESLint({ + fix: true, + overrideConfig: { + parserOptions: { + ecmaVersion: 2017, + sourceType: 'module', + tsconfigRootDir: __dirname, + project: ['./tsconfig.eslint.json'] + }, + env: { + es6: true + }, + plugins: ['@typescript-eslint'], + parser: '@typescript-eslint/parser', + rules: { + 'unused-imports/no-unused-imports-ts': ['off'], + // add blank lines after imports. Otherwise removeUnusedImports() will remove the comment + // of the first item after the import block + 'padding-line-between-statements': [ + 'error', + { 'blankLine': 'always', 'prev': 'import', 'next': '*' } + ] + } + } + }); + const results = await eslint.lintFiles(outputLocation); + await ESLint.outputFixes(results); +} + export async function removeUnusedImports( outputLocation: string ): Promise { diff --git a/repo-scripts/size-analysis/package.json b/repo-scripts/size-analysis/package.json index da760657318..2328082c0de 100644 --- a/repo-scripts/size-analysis/package.json +++ b/repo-scripts/size-analysis/package.json @@ -35,13 +35,13 @@ "typescript": "4.2.2", "terser": "5.5.1", "yargs": "16.2.0", - "@firebase/util": "1.0.0", + "@firebase/util": "1.1.0", "gzip-size": "6.0.0" }, "license": "Apache-2.0", "devDependencies": { "@firebase/logger": "0.2.6", - "@firebase/app": "0.6.20" + "@firebase/app": "0.6.22" }, "repository": { "directory": "repo-scripts/size-analysis", diff --git a/scripts/check_changeset.ts b/scripts/check_changeset.ts index 0d0140fbb46..716ec33692f 100644 --- a/scripts/check_changeset.ts +++ b/scripts/check_changeset.ts @@ -37,12 +37,17 @@ const bumpRank: Record = { // numerical rank, bump text, package name. */ function getHighestBump(changesetPackages: Record) { + const firebasePkgJson = require(resolve( + root, + 'packages/firebase/package.json' + )); let highestBump = bumpRank.patch; let highestBumpText = 'patch'; let bumpPackage = ''; for (const pkgName of Object.keys(changesetPackages)) { if ( pkgName !== 'firebase' && + pkgName in firebasePkgJson.dependencies && bumpRank[changesetPackages[pkgName]] > highestBump ) { highestBump = bumpRank[changesetPackages[pkgName]]; diff --git a/scripts/docgen/content-sources/js/toc.yaml b/scripts/docgen/content-sources/js/toc.yaml index f9c1b73331e..1420bccfcb8 100644 --- a/scripts/docgen/content-sources/js/toc.yaml +++ b/scripts/docgen/content-sources/js/toc.yaml @@ -11,6 +11,16 @@ toc: - title: "App" path: /docs/reference/js/firebase.app.App +- title: "firebase.appcheck" + path: /docs/reference/js/firebase.appcheck + section: + - title: "AppCheck" + path: /docs/reference/js/firebase.appcheck.AppCheck + - title: "AppCheckProvider" + path: /docs/reference/js/firebase.appcheck.AppCheckProvider + - title: "AppCheckToken" + path: /docs/reference/js/firebase.appcheck.AppCheckToken + - title: "firebase.analytics" path: /docs/reference/js/firebase.analytics section: diff --git a/scripts/exp/prepare-util.ts b/scripts/exp/prepare-util.ts index 3579848e98d..5e402de56e8 100644 --- a/scripts/exp/prepare-util.ts +++ b/scripts/exp/prepare-util.ts @@ -68,10 +68,6 @@ export async function createCompatProject(config: CompatConfig) { [srcPkgJson.name]: srcPkgJson.version }; - compatPkgJson.peerDependencies = { - '@firebase/app': '0.x' - }; - compatPkgJson.files = ['dist']; return `${JSON.stringify(compatPkgJson, null, 2)}\n`; diff --git a/scripts/exp/release.ts b/scripts/exp/release.ts index f4445fab5e1..d4778ceedc0 100644 --- a/scripts/exp/release.ts +++ b/scripts/exp/release.ts @@ -487,9 +487,7 @@ async function commitAndPush(versions: Map) { await exec('git add packages-exp/firebase-exp/package.json yarn.lock'); const firebaseExpVersion = versions.get(FIREBASE_UMBRELLA_PACKAGE_NAME); - await exec( - `git commit -m "Publish firebase@exp ${firebaseExpVersion || ''}"` - ); + await exec(`git commit -m "Publish firebase ${firebaseExpVersion || ''}"`); let { stdout: currentBranch, stderr } = await exec( `git rev-parse --abbrev-ref HEAD` diff --git a/scripts/release/utils/yarn.ts b/scripts/release/utils/yarn.ts index a8ce5b488c7..e30e9e14eb5 100644 --- a/scripts/release/utils/yarn.ts +++ b/scripts/release/utils/yarn.ts @@ -32,7 +32,8 @@ export async function reinstallDeps() { export async function buildPackages() { const spinner = ora(' Building Packages').start(); await spawn('yarn', ['build:release'], { - cwd: root + cwd: root, + stdio: 'inherit' }); spinner.stopAndPersist({ symbol: '✅' diff --git a/yarn.lock b/yarn.lock index 3303fe85d87..11009a5f49f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1196,6 +1196,11 @@ resolved "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.6.1.tgz#dcbd23030a71c0c74fc95d4a3f75ba81653850e9" integrity sha512-L/ZnJRAq7F++utfuoTKX4CLBG5YR7tFO3PLzG1/oXXKEezJ0kRL3CMRoueBEmTCzVb/6SIs2Qlaw++uDgi5Xyg== +"@firebase/auth-interop-types@0.1.5": + version "0.1.5" + resolved "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.1.5.tgz#9fc9bd7c879f16b8d1bb08373a0f48c3a8b74557" + integrity sha512-88h74TMQ6wXChPA6h9Q3E1Jg6TkTHep2+k63OWg3s0ozyGVMeY+TTOti7PFPzq5RhszQPQOoCi59es4MaRvgCw== + "@firebase/component@0.1.21": version "0.1.21" resolved "https://registry.npmjs.org/@firebase/component/-/component-0.1.21.tgz#56062eb0d449dc1e7bbef3c084a9b5fa48c7c14d" @@ -7474,7 +7479,7 @@ firebase-admin@9.4.2: "@google-cloud/storage" "^5.3.0" "firebase-exp@file:packages-exp/firebase-exp": - version "0.900.23" + version "9.0.0-beta.1" dependencies: "@firebase/analytics-compat" "0.0.900" "@firebase/analytics-exp" "0.0.900" @@ -7482,8 +7487,8 @@ firebase-admin@9.4.2: "@firebase/app-exp" "0.0.900" "@firebase/auth-compat" "0.0.900" "@firebase/auth-exp" "0.0.900" - "@firebase/database" "0.9.8" - "@firebase/firestore" "2.2.3" + "@firebase/database" "0.9.11" + "@firebase/firestore" "2.2.5" "@firebase/functions-compat" "0.0.900" "@firebase/functions-exp" "0.0.900" "@firebase/messaging-compat" "0.0.900" @@ -7492,7 +7497,7 @@ firebase-admin@9.4.2: "@firebase/performance-exp" "0.0.900" "@firebase/remote-config-compat" "0.0.900" "@firebase/remote-config-exp" "0.0.900" - "@firebase/storage" "0.4.7" + "@firebase/storage" "0.5.1" firebase-functions@3.13.0: version "3.13.0" @@ -14239,6 +14244,16 @@ selenium-webdriver@4.0.0-beta.1, selenium-webdriver@^4.0.0-alpha.7: tmp "^0.2.1" ws "^7.3.1" +selenium-webdriver@^4.0.0-beta.2: + version "4.0.0-beta.3" + resolved "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.0.0-beta.3.tgz#8c29512a27ca9c1f95a96a9a8f488304c894390e" + integrity sha512-R0mGHpQkSKgIWiPgcKDcckh4A6aaK0KTyWxs5ieuiI7zsXQ+Kb6neph+dNoeqq3jSBGyv3ONo2w3oohoL4D/Rg== + dependencies: + jszip "^3.5.0" + rimraf "^2.7.1" + tmp "^0.2.1" + ws "^7.3.1" + semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"