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/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..c4b21dd4d9d --- /dev/null +++ b/packages/app-check-interop-types/package.json @@ -0,0 +1,26 @@ +{ + "name": "@firebase/app-check-interop-types", + "private": true, + "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/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..353edbeed34 --- /dev/null +++ b/packages/app-check-types/index.d.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface FirebaseAppCheck { + /** + * Activate AppCheck + * @param siteKeyOrOrovider - reCAPTCHA sitekey or custom token provider + */ + activate(siteKeyOrProvider: string | AppCheckProvider): void; +} + +interface AppCheckProvider { + /** + * returns an AppCheck token + */ + getToken(): Promise; +} + +interface AppCheckToken { + 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..885d60d2da1 --- /dev/null +++ b/packages/app-check-types/package.json @@ -0,0 +1,26 @@ +{ + "name": "@firebase/app-check-types", + "private": true, + "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/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..c9d61bf00a6 --- /dev/null +++ b/packages/app-check/package.json @@ -0,0 +1,62 @@ +{ + "name": "@firebase/app-check", + "version": "0.1.0", + "private": true, + "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.0.0", + "@firebase/component": "0.4.1", + "@firebase/logger": "0.2.6", + "tslib": "^2.1.0" + }, + "license": "Apache-2.0", + "devDependencies": { + "@firebase/app": "0.6.20", + "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/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..27c8363e5ec --- /dev/null +++ b/packages/app-check/src/api.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 { expect } from 'chai'; +import { stub } from 'sinon'; +import { activate } 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('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; + }); + }); +}); diff --git a/packages/app-check/src/api.ts b/packages/app-check/src/api.ts new file mode 100644 index 00000000000..716052ce237 --- /dev/null +++ b/packages/app-check/src/api.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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 provider - optional custom attestation provider + */ +export function activate( + app: FirebaseApp, + siteKeyOrProvider: string | AppCheckProvider +): 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; + } + + 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() */ + }); + } +} diff --git a/packages/app-check/src/client.test.ts b/packages/app-check/src/client.test.ts new file mode 100644 index 00000000000..7699dd53949 --- /dev/null +++ b/packages/app-check/src/client.test.ts @@ -0,0 +1,187 @@ +/** + * @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 } 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') + ); + + expect(response).to.deep.equal({ + token: 'fake-appcheck-token', + expireTimeMillis: 3600 + }); + }); + + 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') + ); + } 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') + ); + } 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') + ); + } 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') + ); + } 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..f3bb7f13d07 --- /dev/null +++ b/packages/app-check/src/client.ts @@ -0,0 +1,126 @@ +/** + * @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 { AppCheckToken } from '@firebase/app-check-types'; +import { version } from '../package.json'; + +/** + * 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): Promise { + const options = { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + // JS platform identifier + appCheck version only + 'X-Firebase-Client': `fire-js/ fire-app-check/${version}` + } + }; + 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; + + return { + token: responseBody.attestationToken, + expireTimeMillis: Date.now() + timeToLiveAsNumber + }; +} + +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..2e823d4d17d --- /dev/null +++ b/packages/app-check/src/debug.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 { getDebugState } from './state'; +import { readOrCreateDebugTokenFromStorage } from './storage'; +import { Deferred } from '@firebase/util'; + +declare global { + interface Window { + /** + * When it is a string, we treat it as the debug token and give it to consumers as the app check token + * When it is `true`, we will try to read the debug token from the indexeddb, + * if it doesn't exist, create one, print it to console, and ask developers to register it in the Firebase console. + * When it is `undefined`, `false` or any unsupported value type, the SDK will operate in production mode + */ + FIREBASE_APPCHECK_DEBUG_TOKEN?: boolean | string; + } +} + +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 { + if ( + typeof self.FIREBASE_APPCHECK_DEBUG_TOKEN !== 'string' && + self.FIREBASE_APPCHECK_DEBUG_TOKEN !== true + ) { + return; + } + + const debugState = getDebugState(); + debugState.enabled = true; + const deferredToken = new Deferred(); + debugState.token = deferredToken; + + if (typeof self.FIREBASE_APPCHECK_DEBUG_TOKEN === 'string') { + deferredToken.resolve(self.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..f8cc3d4afb3 --- /dev/null +++ b/packages/app-check/src/factory.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseAppCheck, AppCheckProvider } from '@firebase/app-check-types'; +import { activate } from './api'; +import { FirebaseApp } from '@firebase/app-types'; +import { FirebaseAppCheckInternal } from '@firebase/app-check-interop-types'; +import { + getToken, + addTokenListener, + removeTokenListener +} from './internal-api'; + +export function factory(app: FirebaseApp): FirebaseAppCheck { + return { + activate: (siteKeyOrProvider: string | AppCheckProvider) => + activate(app, siteKeyOrProvider) + }; +} + +export function internalFactory(app: FirebaseApp): FirebaseAppCheckInternal { + return { + getToken: forceRefresh => getToken(app, forceRefresh), + addTokenListener: listener => addTokenListener(app, 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..b57064caac4 --- /dev/null +++ b/packages/app-check/src/index.ts @@ -0,0 +1,74 @@ +/** + * @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 } 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'; + +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 + ) + ); + + // 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(); + return internalFactory(app); + }, + ComponentType.PUBLIC + ) + ); + + // TODO: register AppCheck version with firebase.registerVersion() before BETA. We don't want to report version in EAP +} + +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..1261a500a5c --- /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 { AppCheckToken } from '@firebase/app-check-types'; +import { FirebaseApp } from '@firebase/app-types'; +import { ERROR_FACTORY, AppCheckError } from './errors'; +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: AppCheckToken +): 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..850b122a67a --- /dev/null +++ b/packages/app-check/src/internal-api.test.ts @@ -0,0 +1,397 @@ +/** + * @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, + 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'; + +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 + }; + + const fakeCachedAppCheckToken = { + token: 'fake-cached-app-check-token', + expireTimeMillis: 123 + }; + + 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); + + 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); + + 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); + + 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, listener1); + addTokenListener(app, listener2); + + await getToken(app); + + 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, listener1); + addTokenListener(app, listener2); + + await getToken(app); + + 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, listener1); + addTokenListener(app, listener2); + + await getToken(app); + + 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)).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); + 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)).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, 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); + 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, listener); + + expect(getState(app).tokenListeners[0]).to.equal(listener); + }); + + it('starts proactively refreshing token after adding the first listener', () => { + const listener = (): void => {}; + expect(getState(app).tokenListeners.length).to.equal(0); + expect(getState(app).tokenRefresher).to.equal(undefined); + + addTokenListener(app, 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 + } + }); + + addTokenListener(app, 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 + }) + ); + + const fakeListener: AppCheckTokenListener = token => { + expect(token).to.deep.equal({ + token: `fake-cached-app-check-token` + }); + clock.restore(); + done(); + }; + + addTokenListener(app, 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, 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, () => {}); + + const state = getState(app); + expect(state.tokenRefresher).is.undefined; + }); + }); + + describe('removeTokenListener', () => { + it('should remove token listeners', () => { + const listener = (): void => {}; + addTokenListener(app, 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 => {}; + + addTokenListener(app, 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..5da560db0df --- /dev/null +++ b/packages/app-check/src/internal-api.ts @@ -0,0 +1,287 @@ +/** + * @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 { AppCheckToken } from '@firebase/app-check-types'; +import { 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 } from '@firebase/util'; +import { ERROR_FACTORY, AppCheckError } from './errors'; +import { logger } from './logger'; + +// 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, + forceRefresh = false +): Promise { + ensureActivated(app); + /** + * DEBUG MODE + * return the debug token directly + */ + if (isDebugMode()) { + const tokenFromDebugExchange: AppCheckToken = await exchangeToken( + getExchangeDebugTokenRequest(app, await getDebugToken()) + ); + return { token: tokenFromDebugExchange.token }; + } + + const state = getState(app); + + let token: AppCheckToken | 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) { + token = await state.customProvider.getToken(); + } 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) + ); + } + } 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, + 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); + newState.tokenRefresher = tokenRefresher; + } + + if (!newState.tokenRefresher.isRunning()) { + 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): 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); + } else { + result = await getToken(app, 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) { + return Math.max( + 0, + state.token.expireTimeMillis - + Date.now() - + TOKEN_REFRESH_TIME.OFFSET_DURATION + ); + } 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: AppCheckToken): 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..17588bd5b17 --- /dev/null +++ b/packages/app-check/src/state.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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 AppCheckState { + activated: boolean; + tokenListeners: AppCheckTokenListener[]; + customProvider?: AppCheckProvider; + siteKey?: string; + token?: AppCheckToken; + tokenRefresher?: Refresher; + reCAPTCHAState?: ReCAPTCHAState; +} + +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..c7dc21eb59b --- /dev/null +++ b/packages/app-check/src/storage.test.ts @@ -0,0 +1,69 @@ +/** + * @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 + }; + + 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..e893abd2aac --- /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 { AppCheckToken } from '@firebase/app-check-types'; +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'; + +/** + * 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: AppCheckToken +): 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..4d95ed220bd --- /dev/null +++ b/packages/app-check/test/util.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from '@firebase/app-types'; +import { AppCheckProvider } from '@firebase/app-check-types'; +import { GreCAPTCHA, RECAPTCHA_URL } from '../src/recaptcha'; + +export const FAKE_SITE_KEY = 'fake-site-key'; + +export function getFakeApp(): 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 + }; +} + +export function getFakeCustomTokenProvider(): AppCheckProvider { + return { + getToken: () => + Promise.resolve({ + token: 'fake-custom-app-check-token', + expireTimeMillis: 1 + }) + }; +} + +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/database/exp/register.ts b/packages/database/exp/register.ts index 45aafe51fa3..9814f3f2a2e 100644 --- a/packages/database/exp/register.ts +++ b/packages/database/exp/register.ts @@ -38,7 +38,13 @@ export function registerDatabase(variant?: string): void { (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 5388bd2d102..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 ); }, diff --git a/packages/database/index.ts b/packages/database/index.ts index ee0d0363b44..800faf8b90d 100644 --- a/packages/database/index.ts +++ b/packages/database/index.ts @@ -46,8 +46,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/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/core/AppCheckTokenProvider.ts b/packages/database/src/core/AppCheckTokenProvider.ts new file mode 100644 index 00000000000..b50f147b5a5 --- /dev/null +++ b/packages/database/src/core/AppCheckTokenProvider.ts @@ -0,0 +1,62 @@ +/** + * @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 Promise.resolve(null); + } + 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/PersistentConnection.ts b/packages/database/src/core/PersistentConnection.ts index 0c0762a8d22..d700cb0fe43 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(); @@ -372,6 +376,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 +424,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; @@ -641,6 +687,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 { @@ -764,7 +815,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(); @@ -800,42 +851,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.'); - this.authToken_ = result && result.accessToken; - connection = new Connection( - connId, - this.repoInfo_, - this.applicationId_, - onDataMessage, - onReady, - onDisconnect, - /* onKill= */ reason => { - warn(reason + ' (' + this.repoInfo_.toString() + ')'); - this.interrupt(SERVER_KILL_INTERRUPT_REASON); - }, - lastSessionId - ); - } else { - log('getToken() completed but was canceled'); - } - }) - .then(null, 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(); + 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(); + } + } } } @@ -931,7 +988,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; @@ -942,6 +999,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); @@ -957,6 +1031,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 005c663f74c..d3938a75cc0 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/exp/Database.ts b/packages/database/src/exp/Database.ts index f70d797be3e..92a6f295f9d 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, @@ -26,6 +27,7 @@ import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; import { getModularInstance } from '@firebase/util'; +import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider'; import { AuthTokenProvider, EmulatorAdminTokenProvider, @@ -98,6 +100,7 @@ function repoManagerApplyEmulatorSettings( export function repoManagerDatabaseFromApp( app: FirebaseApp, authProvider: Provider, + appCheckProvider?: Provider, url?: string, nodeAdmin?: boolean ): FirebaseDatabase { @@ -146,7 +149,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 +182,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 +198,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; 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..6038f473a7d 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,12 @@ export class WebSocketConnection implements Transport { } }; + if (this.nodeAdmin) { + options.headers['Authorization'] = this.authToken || ''; + } else { + 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 +187,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/firebase/index.d.ts b/packages/firebase/index.d.ts index d1c5ef64870..45bf03391db 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -1274,6 +1274,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 +1452,41 @@ 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 siteKeyOrOrovider - reCAPTCHA sitekey or custom token provider + */ + activate(siteKeyOrProvider: string | AppCheckProvider): void; + } + + interface AppCheckProvider { + /** + * returns an AppCheck token + */ + getToken(): Promise; + } + + interface AppCheckToken { + readonly token: string; + /** + * The local timestamp after which the token will expire. + */ + readonly expireTimeMillis: number; } } 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/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/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/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/src/implementation/error.ts b/packages/storage/src/implementation/error.ts index e58e969395b..41a29fd5dff 100644 --- a/packages/storage/src/implementation/error.ts +++ b/packages/storage/src/implementation/error.ts @@ -83,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', @@ -152,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) ); }