diff --git a/.changeset/mean-elephants-rush.md b/.changeset/mean-elephants-rush.md new file mode 100644 index 00000000000..8c06e72a276 --- /dev/null +++ b/.changeset/mean-elephants-rush.md @@ -0,0 +1,6 @@ +--- +"@firebase/firestore": minor +firebase: minor +--- + +AppCheck integration for Firestore diff --git a/packages/firestore-compat/src/index.console.ts b/packages/firestore-compat/src/index.console.ts index 0dc7105d400..7c9a7fc63a2 100644 --- a/packages/firestore-compat/src/index.console.ts +++ b/packages/firestore-compat/src/index.console.ts @@ -21,7 +21,8 @@ import { _DatabaseId, Firestore as FirestoreExp, FirestoreError, - _EmptyCredentialsProvider + _EmptyAuthCredentialsProvider, + _EmptyAppCheckTokenProvider } from '@firebase/firestore'; import { @@ -91,7 +92,8 @@ export class Firestore extends FirestoreCompat { databaseIdFromFirestoreDatabase(firestoreDatabase), new FirestoreExp( databaseIdFromFirestoreDatabase(firestoreDatabase), - new _EmptyCredentialsProvider() + new _EmptyAuthCredentialsProvider(), + new _EmptyAppCheckTokenProvider() ), new MemoryPersistenceProvider() ); diff --git a/packages/firestore/externs.json b/packages/firestore/externs.json index 7b60d0bbecf..003eed2d069 100644 --- a/packages/firestore/externs.json +++ b/packages/firestore/externs.json @@ -14,6 +14,7 @@ "packages/app-types/index.d.ts", "packages/app-types/private.d.ts", "packages/app/dist/app.d.ts", + "packages/app-check-interop-types/index.d.ts", "packages/auth-interop-types/index.d.ts", "packages/firestore/dist/lite/internal.d.ts", "packages/firestore/dist/internal.d.ts", diff --git a/packages/firestore/lite/register.ts b/packages/firestore/lite/register.ts index 867a55707f6..1200b68ad73 100644 --- a/packages/firestore/lite/register.ts +++ b/packages/firestore/lite/register.ts @@ -23,7 +23,10 @@ import { import { Component, ComponentType } from '@firebase/component'; import { version } from '../package.json'; -import { LiteCredentialsProvider } from '../src/api/credentials'; +import { + LiteAppCheckTokenProvider, + LiteAuthCredentialsProvider +} from '../src/api/credentials'; import { setSDKVersion } from '../src/core/version'; import { Firestore } from '../src/lite-api/database'; import { FirestoreSettings } from '../src/lite-api/settings'; @@ -43,7 +46,12 @@ export function registerFirestore(): void { const app = container.getProvider('app').getImmediate()!; const firestoreInstance = new Firestore( app, - new LiteCredentialsProvider(container.getProvider('auth-internal')) + new LiteAuthCredentialsProvider( + container.getProvider('auth-internal') + ), + new LiteAppCheckTokenProvider( + container.getProvider('app-check-internal') + ) ); if (settings) { firestoreInstance._setSettings(settings); diff --git a/packages/firestore/src/api.ts b/packages/firestore/src/api.ts index e66fda26582..aab20b52e8e 100644 --- a/packages/firestore/src/api.ts +++ b/packages/firestore/src/api.ts @@ -156,4 +156,5 @@ export { FieldPath as _FieldPath } from './model/path'; export type { ResourcePath as _ResourcePath } from './model/path'; export type { ByteString as _ByteString } from './util/byte_string'; export { logWarn as _logWarn } from './util/log'; -export { EmptyCredentialsProvider as _EmptyCredentialsProvider } from './api/credentials'; +export { EmptyAuthCredentialsProvider as _EmptyAuthCredentialsProvider } from './api/credentials'; +export { EmptyAppCheckTokenProvider as _EmptyAppCheckTokenProvider } from './api/credentials'; diff --git a/packages/firestore/src/api/credentials.ts b/packages/firestore/src/api/credentials.ts index 12352cae958..b8277beb3ec 100644 --- a/packages/firestore/src/api/credentials.ts +++ b/packages/firestore/src/api/credentials.ts @@ -15,6 +15,12 @@ * limitations under the License. */ +import { + AppCheckInternalComponentName, + AppCheckTokenListener, + AppCheckTokenResult, + FirebaseAppCheckInternal +} from '@firebase/app-check-interop-types'; import { FirebaseAuthInternal, FirebaseAuthInternalName @@ -42,7 +48,7 @@ export interface FirstPartyCredentialsSettings { export interface ProviderCredentialsSettings { // These are external types. Prevent minification. ['type']: 'provider'; - ['client']: CredentialsProvider; + ['client']: CredentialsProvider; } /** Settings for private credentials */ @@ -50,7 +56,7 @@ export type CredentialsSettings = | FirstPartyCredentialsSettings | ProviderCredentialsSettings; -export type TokenType = 'OAuth' | 'FirstParty'; +export type TokenType = 'OAuth' | 'FirstParty' | 'AppCheck'; export interface Token { /** Type of token. */ type: TokenType; @@ -58,20 +64,20 @@ export interface Token { /** * The user with which the token is associated (used for persisting user * state on disk, etc.). + * This will be null for Tokens of the type 'AppCheck'. */ - user: User; + user?: User; - /** Extra header values to be passed along with a request */ - authHeaders: { [header: string]: string }; + /** Header values to set for this token */ + headers: Map; } export class OAuthToken implements Token { type = 'OAuth' as TokenType; - authHeaders: { [header: string]: string }; + headers = new Map(); + constructor(value: string, public user: User) { - this.authHeaders = {}; - // Set the headers using Object Literal notation to avoid minification - this.authHeaders['Authorization'] = `Bearer ${value}`; + this.headers.set('Authorization', `Bearer ${value}`); } } @@ -80,13 +86,13 @@ export class OAuthToken implements Token { * token and may need to invalidate other state if the current user has also * changed. */ -export type CredentialChangeListener = (user: User) => Promise; +export type CredentialChangeListener = (credential: T) => Promise; /** * Provides methods for getting the uid and token for the current user and * listening for changes. */ -export interface CredentialsProvider { +export interface CredentialsProvider { /** * Starts the credentials provider and specifies a listener to be notified of * credential changes (sign-in / sign-out, token changes). It is immediately @@ -94,7 +100,10 @@ export interface CredentialsProvider { * * The change listener is invoked on the provided AsyncQueue. */ - start(asyncQueue: AsyncQueue, changeListener: CredentialChangeListener): void; + start( + asyncQueue: AsyncQueue, + changeListener: CredentialChangeListener + ): void; /** Requests a token for the current user. */ getToken(): Promise; @@ -112,7 +121,7 @@ export interface CredentialsProvider { * A CredentialsProvider that always yields an empty token. * @internal */ -export class EmptyCredentialsProvider implements CredentialsProvider { +export class EmptyAuthCredentialsProvider implements CredentialsProvider { getToken(): Promise { return Promise.resolve(null); } @@ -121,7 +130,7 @@ export class EmptyCredentialsProvider implements CredentialsProvider { start( asyncQueue: AsyncQueue, - changeListener: CredentialChangeListener + changeListener: CredentialChangeListener ): void { // Fire with initial user. asyncQueue.enqueueRetryable(() => changeListener(User.UNAUTHENTICATED)); @@ -134,7 +143,9 @@ export class EmptyCredentialsProvider implements CredentialsProvider { * A CredentialsProvider that always returns a constant token. Used for * emulator token mocking. */ -export class EmulatorCredentialsProvider implements CredentialsProvider { +export class EmulatorAuthCredentialsProvider + implements CredentialsProvider +{ constructor(private token: Token) {} /** @@ -142,7 +153,7 @@ export class EmulatorCredentialsProvider implements CredentialsProvider { * This isn't actually necessary since the UID never changes, but we use this * to verify the listen contract is adhered to in tests. */ - private changeListener: CredentialChangeListener | null = null; + private changeListener: CredentialChangeListener | null = null; getToken(): Promise { return Promise.resolve(this.token); @@ -152,7 +163,7 @@ export class EmulatorCredentialsProvider implements CredentialsProvider { start( asyncQueue: AsyncQueue, - changeListener: CredentialChangeListener + changeListener: CredentialChangeListener ): void { debugAssert( !this.changeListener, @@ -160,7 +171,7 @@ export class EmulatorCredentialsProvider implements CredentialsProvider { ); this.changeListener = changeListener; // Fire with initial user. - asyncQueue.enqueueRetryable(() => changeListener(this.token.user)); + asyncQueue.enqueueRetryable(() => changeListener(this.token.user!)); } shutdown(): void { @@ -169,7 +180,7 @@ export class EmulatorCredentialsProvider implements CredentialsProvider { } /** Credential provider for the Lite SDK. */ -export class LiteCredentialsProvider implements CredentialsProvider { +export class LiteAuthCredentialsProvider implements CredentialsProvider { private auth: FirebaseAuthInternal | null = null; constructor(authProvider: Provider) { @@ -203,13 +214,15 @@ export class LiteCredentialsProvider implements CredentialsProvider { start( asyncQueue: AsyncQueue, - changeListener: CredentialChangeListener + changeListener: CredentialChangeListener ): void {} shutdown(): void {} } -export class FirebaseCredentialsProvider implements CredentialsProvider { +export class FirebaseAuthCredentialsProvider + implements CredentialsProvider +{ /** * The auth token listener registered with FirebaseApp, retained here so we * can unregister it. @@ -233,7 +246,7 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { start( asyncQueue: AsyncQueue, - changeListener: CredentialChangeListener + changeListener: CredentialChangeListener ): void { let lastTokenId = this.tokenCounter; @@ -270,7 +283,7 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { }; const registerAuth = (auth: FirebaseAuthInternal): void => { - logDebug('FirebaseCredentialsProvider', 'Auth detected'); + logDebug('FirebaseAuthCredentialsProvider', 'Auth detected'); this.auth = auth; this.auth.addAuthTokenListener(this.tokenListener); awaitNextToken(); @@ -288,7 +301,7 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { registerAuth(auth); } else { // If auth is still not available, proceed with `null` user - logDebug('FirebaseCredentialsProvider', 'Auth not yet detected'); + logDebug('FirebaseAuthCredentialsProvider', 'Auth not yet detected'); nextToken.resolve(); nextToken = new Deferred(); } @@ -301,7 +314,7 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { getToken(): Promise { debugAssert( this.tokenListener != null, - 'FirebaseCredentialsProvider not started.' + 'FirebaseAuthCredentialsProvider not started.' ); // Take note of the current value of the tokenCounter so that this method @@ -321,7 +334,7 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { // user, we can't be sure). if (this.tokenCounter !== initialTokenCounter) { logDebug( - 'FirebaseCredentialsProvider', + 'FirebaseAuthCredentialsProvider', 'getToken aborted due to token change.' ); return this.getToken(); @@ -382,26 +395,17 @@ interface Gapi { export class FirstPartyToken implements Token { type = 'FirstParty' as TokenType; user = User.FIRST_PARTY; + headers = new Map(); - constructor( - private gapi: Gapi, - private sessionIndex: string, - private iamToken: string | null - ) {} - - get authHeaders(): { [header: string]: string } { - const headers: { [header: string]: string } = { - 'X-Goog-AuthUser': this.sessionIndex - }; - // Use array notation to prevent minification - const authHeader = this.gapi['auth']['getAuthHeaderValueForFirstParty']([]); + constructor(gapi: Gapi, sessionIndex: string, iamToken: string | null) { + this.headers.set('X-Goog-AuthUser', sessionIndex); + const authHeader = gapi['auth']['getAuthHeaderValueForFirstParty']([]); if (authHeader) { - headers['Authorization'] = authHeader; + this.headers.set('Authorization', authHeader); } - if (this.iamToken) { - headers['X-Goog-Iam-Authorization-Token'] = this.iamToken; + if (iamToken) { + this.headers.set('X-Goog-Iam-Authorization-Token', iamToken); } - return headers; } } @@ -410,7 +414,9 @@ export class FirstPartyToken implements Token { * to authenticate the user, using technique that is only available * to applications hosted by Google. */ -export class FirstPartyCredentialsProvider implements CredentialsProvider { +export class FirstPartyAuthCredentialsProvider + implements CredentialsProvider +{ constructor( private gapi: Gapi, private sessionIndex: string, @@ -425,7 +431,7 @@ export class FirstPartyCredentialsProvider implements CredentialsProvider { start( asyncQueue: AsyncQueue, - changeListener: CredentialChangeListener + changeListener: CredentialChangeListener ): void { // Fire with initial uid. asyncQueue.enqueueRetryable(() => changeListener(User.FIRST_PARTY)); @@ -436,15 +442,184 @@ export class FirstPartyCredentialsProvider implements CredentialsProvider { invalidateToken(): void {} } +export class AppCheckToken implements Token { + type = 'AppCheck' as TokenType; + headers = new Map(); + + constructor(private value: string) { + if (value && value.length > 0) { + this.headers.set('x-firebase-appcheck', this.value); + } + } +} + +export class FirebaseAppCheckTokenProvider + implements CredentialsProvider +{ + /** + * The AppCheck token listener registered with FirebaseApp, retained here so + * we can unregister it. + */ + private tokenListener!: AppCheckTokenListener; + + private forceRefresh = false; + + private appCheck: FirebaseAppCheckInternal | null = null; + + constructor( + private appCheckProvider: Provider + ) {} + + start( + asyncQueue: AsyncQueue, + changeListener: CredentialChangeListener + ): void { + const onTokenChanged: (tokenResult: AppCheckTokenResult) => Promise = + tokenResult => { + if (tokenResult.error != null) { + logDebug( + 'FirebaseAppCheckTokenProvider', + `Error getting App Check token; using placeholder token instead. Error: ${tokenResult.error.message}` + ); + } + return changeListener(tokenResult.token); + }; + + this.tokenListener = (tokenResult: AppCheckTokenResult) => { + asyncQueue.enqueueRetryable(() => onTokenChanged(tokenResult)); + }; + + const registerAppCheck = (appCheck: FirebaseAppCheckInternal): void => { + logDebug('FirebaseAppCheckTokenProvider', 'AppCheck detected'); + this.appCheck = appCheck; + this.appCheck.addTokenListener(this.tokenListener); + }; + + this.appCheckProvider.onInit(appCheck => registerAppCheck(appCheck)); + + // Our users can initialize AppCheck after Firestore, so we give it + // a chance to register itself with the component framework. + setTimeout(() => { + if (!this.appCheck) { + const appCheck = this.appCheckProvider.getImmediate({ optional: true }); + if (appCheck) { + registerAppCheck(appCheck); + } else { + // If AppCheck is still not available, proceed without it. + logDebug( + 'FirebaseAppCheckTokenProvider', + 'AppCheck not yet detected' + ); + } + } + }, 0); + } + + getToken(): Promise { + debugAssert( + this.tokenListener != null, + 'FirebaseAppCheckTokenProvider not started.' + ); + + const forceRefresh = this.forceRefresh; + this.forceRefresh = false; + + if (!this.appCheck) { + return Promise.resolve(null); + } + + return this.appCheck.getToken(forceRefresh).then(tokenResult => { + if (tokenResult) { + hardAssert( + typeof tokenResult.token === 'string', + 'Invalid tokenResult returned from getToken():' + tokenResult + ); + return new AppCheckToken(tokenResult.token); + } else { + return null; + } + }); + } + + invalidateToken(): void { + this.forceRefresh = true; + } + + shutdown(): void { + if (this.appCheck) { + this.appCheck.removeTokenListener(this.tokenListener!); + } + } +} + +/** + * An AppCheck token provider that always yields an empty token. + * @internal + */ +export class EmptyAppCheckTokenProvider implements CredentialsProvider { + getToken(): Promise { + return Promise.resolve(new AppCheckToken('')); + } + + invalidateToken(): void {} + + start( + asyncQueue: AsyncQueue, + changeListener: CredentialChangeListener + ): void {} + + shutdown(): void {} +} + +/** AppCheck token provider for the Lite SDK. */ +export class LiteAppCheckTokenProvider implements CredentialsProvider { + private appCheck: FirebaseAppCheckInternal | null = null; + + constructor( + private appCheckProvider: Provider + ) { + appCheckProvider.onInit(appCheck => { + this.appCheck = appCheck; + }); + } + + getToken(): Promise { + if (!this.appCheck) { + return Promise.resolve(null); + } + + return this.appCheck.getToken().then(tokenResult => { + if (tokenResult) { + hardAssert( + typeof tokenResult.token === 'string', + 'Invalid tokenResult returned from getToken():' + tokenResult + ); + return new AppCheckToken(tokenResult.token); + } else { + return null; + } + }); + } + + invalidateToken(): void {} + + start( + asyncQueue: AsyncQueue, + changeListener: CredentialChangeListener + ): void {} + + shutdown(): void {} +} + /** * Builds a CredentialsProvider depending on the type of * the credentials passed in. */ -export function makeCredentialsProvider( +export function makeAuthCredentialsProvider( credentials?: CredentialsSettings -): CredentialsProvider { +): CredentialsProvider { if (!credentials) { - return new EmptyCredentialsProvider(); + return new EmptyAuthCredentialsProvider(); } switch (credentials['type']) { @@ -460,7 +635,7 @@ export function makeCredentialsProvider( ), 'unexpected gapi interface' ); - return new FirstPartyCredentialsProvider( + return new FirstPartyAuthCredentialsProvider( client, credentials['sessionIndex'] || '0', credentials['iamToken'] || null @@ -472,7 +647,7 @@ export function makeCredentialsProvider( default: throw new FirestoreError( Code.INVALID_ARGUMENT, - 'makeCredentialsProvider failed due to invalid credential type' + 'makeAuthCredentialsProvider failed due to invalid credential type' ); } } diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index c4a74e4e541..0dee34d1c2c 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -24,6 +24,7 @@ import { } from '@firebase/app'; import { deepEqual } from '@firebase/util'; +import { User } from '../auth/user'; import { IndexedDbOfflineComponentProvider, MultiTabOfflineComponentProvider, @@ -102,9 +103,14 @@ export class Firestore extends LiteFirestore { /** @hideconstructor */ constructor( databaseIdOrApp: DatabaseId | FirebaseApp, - credentialsProvider: CredentialsProvider + authCredentialsProvider: CredentialsProvider, + appCheckCredentialsProvider: CredentialsProvider ) { - super(databaseIdOrApp, credentialsProvider); + super( + databaseIdOrApp, + authCredentialsProvider, + appCheckCredentialsProvider + ); this._persistenceKey = 'name' in databaseIdOrApp ? databaseIdOrApp.name : '[DEFAULT]'; } @@ -207,7 +213,8 @@ export function configureFirestore(firestore: Firestore): void { settings ); firestore._firestoreClient = new FirestoreClient( - firestore._credentials, + firestore._authCredentials, + firestore._appCheckCredentials, firestore._queue, databaseInfo ); diff --git a/packages/firestore/src/core/component_provider.ts b/packages/firestore/src/core/component_provider.ts index 9c847ffd34c..1cf6a51db2b 100644 --- a/packages/firestore/src/core/component_provider.ts +++ b/packages/firestore/src/core/component_provider.ts @@ -75,7 +75,8 @@ import { OnlineStateSource } from './types'; export interface ComponentConfiguration { asyncQueue: AsyncQueue; databaseInfo: DatabaseInfo; - credentials: CredentialsProvider; + authCredentials: CredentialsProvider; + appCheckCredentials: CredentialsProvider; clientId: ClientId; initialUser: User; maxConcurrentLimboResolutions: number; @@ -371,7 +372,12 @@ export class OnlineComponentProvider { createDatastore(cfg: ComponentConfiguration): Datastore { const serializer = newSerializer(cfg.databaseInfo.databaseId); const connection = newConnection(cfg.databaseInfo); - return newDatastore(cfg.credentials, connection, serializer); + return newDatastore( + cfg.authCredentials, + cfg.appCheckCredentials, + connection, + serializer + ); } createRemoteStore(cfg: ComponentConfiguration): RemoteStore { diff --git a/packages/firestore/src/core/firestore_client.ts b/packages/firestore/src/core/firestore_client.ts index d8e5b04840a..dabdc35edf3 100644 --- a/packages/firestore/src/core/firestore_client.ts +++ b/packages/firestore/src/core/firestore_client.ts @@ -97,14 +97,15 @@ export const MAX_CONCURRENT_LIMBO_RESOLUTIONS = 100; export class FirestoreClient { private user = User.UNAUTHENTICATED; private readonly clientId = AutoId.newId(); - private credentialListener: CredentialChangeListener = () => + private authCredentialListener: CredentialChangeListener = () => Promise.resolve(); offlineComponents?: OfflineComponentProvider; onlineComponents?: OnlineComponentProvider; constructor( - private credentials: CredentialsProvider, + private authCredentials: CredentialsProvider, + private appCheckCredentials: CredentialsProvider, /** * Asynchronous queue responsible for all of our internal processing. When * we get incoming work from the user (via public API) or the network @@ -116,11 +117,13 @@ export class FirestoreClient { public asyncQueue: AsyncQueue, private databaseInfo: DatabaseInfo ) { - this.credentials.start(asyncQueue, async user => { + this.authCredentials.start(asyncQueue, async user => { logDebug(LOG_TAG, 'Received user=', user.uid); - await this.credentialListener(user); + await this.authCredentialListener(user); this.user = user; }); + // Register an empty credentials change listener to activate token refresh. + this.appCheckCredentials.start(asyncQueue, () => Promise.resolve()); } async getConfiguration(): Promise { @@ -128,14 +131,15 @@ export class FirestoreClient { asyncQueue: this.asyncQueue, databaseInfo: this.databaseInfo, clientId: this.clientId, - credentials: this.credentials, + authCredentials: this.authCredentials, + appCheckCredentials: this.appCheckCredentials, initialUser: this.user, maxConcurrentLimboResolutions: MAX_CONCURRENT_LIMBO_RESOLUTIONS }; } setCredentialChangeListener(listener: (user: User) => Promise): void { - this.credentialListener = listener; + this.authCredentialListener = listener; } /** @@ -166,7 +170,8 @@ export class FirestoreClient { // The credentials provider must be terminated after shutting down the // RemoteStore as it will prevent the RemoteStore from retrieving auth // tokens. - this.credentials.shutdown(); + this.authCredentials.shutdown(); + this.appCheckCredentials.shutdown(); deferred.resolve(); } catch (e) { const firestoreError = wrapInUserErrorIfRecoverable( diff --git a/packages/firestore/src/lite-api/components.ts b/packages/firestore/src/lite-api/components.ts index 8dfaa32d1b9..8280d396b0b 100644 --- a/packages/firestore/src/lite-api/components.ts +++ b/packages/firestore/src/lite-api/components.ts @@ -19,6 +19,7 @@ import { _FirebaseService } from '@firebase/app'; import { CredentialsProvider } from '../api/credentials'; +import { User } from '../auth/user'; import { DatabaseId, DatabaseInfo } from '../core/database_info'; import { newConnection } from '../platform/connection'; import { newSerializer } from '../platform/serializer'; @@ -41,7 +42,8 @@ export const LOG_TAG = 'ComponentProvider'; * This interface mainly exists to remove a cyclic dependency. */ export interface FirestoreService extends _FirebaseService { - _credentials: CredentialsProvider; + _authCredentials: CredentialsProvider; + _appCheckCredentials: CredentialsProvider; _persistenceKey: string; _databaseId: DatabaseId; _terminated: boolean; @@ -77,7 +79,8 @@ export function getDatastore(firestore: FirestoreService): Datastore { const connection = newConnection(databaseInfo); const serializer = newSerializer(firestore._databaseId); const datastore = newDatastore( - firestore._credentials, + firestore._authCredentials, + firestore._appCheckCredentials, connection, serializer ); diff --git a/packages/firestore/src/lite-api/database.ts b/packages/firestore/src/lite-api/database.ts index a082af975d3..07db0a3c868 100644 --- a/packages/firestore/src/lite-api/database.ts +++ b/packages/firestore/src/lite-api/database.ts @@ -26,8 +26,8 @@ import { createMockUserToken, EmulatorMockTokenOptions } from '@firebase/util'; import { CredentialsProvider, - EmulatorCredentialsProvider, - makeCredentialsProvider, + EmulatorAuthCredentialsProvider, + makeAuthCredentialsProvider, OAuthToken } from '../api/credentials'; import { User } from '../auth/user'; @@ -76,7 +76,8 @@ export class Firestore implements FirestoreService { /** @hideconstructor */ constructor( databaseIdOrApp: DatabaseId | FirebaseApp, - public _credentials: CredentialsProvider + public _authCredentials: CredentialsProvider, + public _appCheckCredentials: CredentialsProvider ) { if (databaseIdOrApp instanceof DatabaseId) { this._databaseId = databaseIdOrApp; @@ -120,7 +121,7 @@ export class Firestore implements FirestoreService { } this._settings = new FirestoreSettingsImpl(settings); if (settings.credentials !== undefined) { - this._credentials = makeCredentialsProvider(settings.credentials); + this._authCredentials = makeAuthCredentialsProvider(settings.credentials); } } @@ -274,7 +275,7 @@ export function connectFirestoreEmulator( user = new User(uid); } - firestore._credentials = new EmulatorCredentialsProvider( + firestore._authCredentials = new EmulatorAuthCredentialsProvider( new OAuthToken(token, user) ); } diff --git a/packages/firestore/src/platform/browser/webchannel_connection.ts b/packages/firestore/src/platform/browser/webchannel_connection.ts index 239ba6a44de..1f22f025607 100644 --- a/packages/firestore/src/platform/browser/webchannel_connection.ts +++ b/packages/firestore/src/platform/browser/webchannel_connection.ts @@ -160,7 +160,8 @@ export class WebChannelConnection extends RestConnection { openStream( rpcName: string, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Stream { const urlParts = [ this.baseUrl, @@ -201,7 +202,11 @@ export class WebChannelConnection extends RestConnection { request.xmlHttpFactory = new FetchXmlHttpFactory({}); } - this.modifyHeadersForRequest(request.initMessageHeaders!, token); + this.modifyHeadersForRequest( + request.initMessageHeaders!, + authToken, + appCheckToken + ); // Sending the custom headers we just added to request.initMessageHeaders // (Authorization, etc.) will trigger the browser to make a CORS preflight diff --git a/packages/firestore/src/platform/node/grpc_connection.ts b/packages/firestore/src/platform/node/grpc_connection.ts index 613d4e3f8cb..78c08869b18 100644 --- a/packages/firestore/src/platform/node/grpc_connection.ts +++ b/packages/firestore/src/platform/node/grpc_connection.ts @@ -49,21 +49,20 @@ const X_GOOG_API_CLIENT_VALUE = `gl-node/${process.versions.node} fire/${SDK_VER function createMetadata( databasePath: string, - token: Token | null, + authToken: Token | null, + appCheckToken: Token | null, appId: string ): Metadata { hardAssert( - token === null || token.type === 'OAuth', + authToken === null || authToken.type === 'OAuth', 'If provided, token must be OAuth' ); - const metadata = new Metadata(); - if (token) { - for (const header in token.authHeaders) { - if (token.authHeaders.hasOwnProperty(header)) { - metadata.set(header, token.authHeaders[header]); - } - } + if (authToken) { + authToken.headers.forEach((value, key) => metadata.set(key, value)); + } + if (appCheckToken) { + appCheckToken.headers.forEach((value, key) => metadata.set(key, value)); } if (appId) { metadata.set('X-Firebase-GMPID', appId); @@ -115,12 +114,14 @@ export class GrpcConnection implements Connection { rpcName: string, path: string, request: Req, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Promise { const stub = this.ensureActiveStub(); const metadata = createMetadata( this.databasePath, - token, + authToken, + appCheckToken, this.databaseInfo.appId ); const jsonRequest = { database: this.databasePath, ...request }; @@ -156,7 +157,8 @@ export class GrpcConnection implements Connection { rpcName: string, path: string, request: Req, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Promise { const results: Resp[] = []; const responseDeferred = new Deferred(); @@ -169,7 +171,8 @@ export class GrpcConnection implements Connection { const stub = this.ensureActiveStub(); const metadata = createMetadata( this.databasePath, - token, + authToken, + appCheckToken, this.databaseInfo.appId ); const jsonRequest = { ...request, database: this.databasePath }; @@ -194,12 +197,14 @@ export class GrpcConnection implements Connection { // TODO(mikelehen): This "method" is a monster. Should be refactored. openStream( rpcName: string, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Stream { const stub = this.ensureActiveStub(); const metadata = createMetadata( this.databasePath, - token, + authToken, + appCheckToken, this.databaseInfo.appId ); const grpcStream = stub[rpcName](metadata); diff --git a/packages/firestore/src/register.ts b/packages/firestore/src/register.ts index 58343e8a470..35646080a47 100644 --- a/packages/firestore/src/register.ts +++ b/packages/firestore/src/register.ts @@ -23,7 +23,10 @@ import { import { Component, ComponentType } from '@firebase/component'; import { name, version } from '../package.json'; -import { FirebaseCredentialsProvider } from '../src/api/credentials'; +import { + FirebaseAppCheckTokenProvider, + FirebaseAuthCredentialsProvider +} from '../src/api/credentials'; import { setSDKVersion } from '../src/core/version'; import { Firestore } from './api/database'; @@ -41,8 +44,11 @@ export function registerFirestore( const app = container.getProvider('app').getImmediate()!; const firestoreInstance = new Firestore( app, - new FirebaseCredentialsProvider( + new FirebaseAuthCredentialsProvider( container.getProvider('auth-internal') + ), + new FirebaseAppCheckTokenProvider( + container.getProvider('app-check-internal') ) ); settings = { useFetchStreams, ...settings }; diff --git a/packages/firestore/src/remote/connection.ts b/packages/firestore/src/remote/connection.ts index 2aa981c0a5d..1a88c5c48c8 100644 --- a/packages/firestore/src/remote/connection.ts +++ b/packages/firestore/src/remote/connection.ts @@ -47,7 +47,8 @@ export interface Connection { rpcName: string, path: string, request: Req, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Promise; /** @@ -66,7 +67,8 @@ export interface Connection { rpcName: string, path: string, request: Req, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Promise; /** @@ -77,7 +79,8 @@ export interface Connection { */ openStream( rpcName: string, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Stream; // TODO(mcg): subscribe to connection state changes. diff --git a/packages/firestore/src/remote/datastore.ts b/packages/firestore/src/remote/datastore.ts index 944a59e5b4c..e6ee7710a04 100644 --- a/packages/firestore/src/remote/datastore.ts +++ b/packages/firestore/src/remote/datastore.ts @@ -16,6 +16,7 @@ */ import { CredentialsProvider } from '../api/credentials'; +import { User } from '../auth/user'; import { Query, queryToTarget } from '../core/query'; import { Document } from '../model/document'; import { DocumentKey } from '../model/document_key'; @@ -64,7 +65,8 @@ class DatastoreImpl extends Datastore { terminated = false; constructor( - readonly credentials: CredentialsProvider, + readonly authCredentials: CredentialsProvider, + readonly appCheckCredentials: CredentialsProvider, readonly connection: Connection, readonly serializer: JsonProtoSerializer ) { @@ -81,27 +83,31 @@ class DatastoreImpl extends Datastore { } } - /** Gets an auth token and invokes the provided RPC. */ + /** Invokes the provided RPC with auth and AppCheck tokens. */ invokeRPC( rpcName: string, path: string, request: Req ): Promise { this.verifyInitialized(); - return this.credentials - .getToken() - .then(token => { + return Promise.all([ + this.authCredentials.getToken(), + this.appCheckCredentials.getToken() + ]) + .then(([authToken, appCheckToken]) => { return this.connection.invokeRPC( rpcName, path, request, - token + authToken, + appCheckToken ); }) .catch((error: FirestoreError) => { if (error.name === 'FirebaseError') { if (error.code === Code.UNAUTHENTICATED) { - this.credentials.invalidateToken(); + this.authCredentials.invalidateToken(); + this.appCheckCredentials.invalidateToken(); } throw error; } else { @@ -110,27 +116,31 @@ class DatastoreImpl extends Datastore { }); } - /** Gets an auth token and invokes the provided RPC with streamed results. */ + /** Invokes the provided RPC with streamed results with auth and AppCheck tokens. */ invokeStreamingRPC( rpcName: string, path: string, request: Req ): Promise { this.verifyInitialized(); - return this.credentials - .getToken() - .then(token => { + return Promise.all([ + this.authCredentials.getToken(), + this.appCheckCredentials.getToken() + ]) + .then(([authToken, appCheckToken]) => { return this.connection.invokeStreamingRPC( rpcName, path, request, - token + authToken, + appCheckToken ); }) .catch((error: FirestoreError) => { if (error.name === 'FirebaseError') { if (error.code === Code.UNAUTHENTICATED) { - this.credentials.invalidateToken(); + this.authCredentials.invalidateToken(); + this.appCheckCredentials.invalidateToken(); } throw error; } else { @@ -147,11 +157,17 @@ class DatastoreImpl extends Datastore { // TODO(firestorexp): Make sure there is only one Datastore instance per // firestore-exp client. export function newDatastore( - credentials: CredentialsProvider, + authCredentials: CredentialsProvider, + appCheckCredentials: CredentialsProvider, connection: Connection, serializer: JsonProtoSerializer ): Datastore { - return new DatastoreImpl(credentials, connection, serializer); + return new DatastoreImpl( + authCredentials, + appCheckCredentials, + connection, + serializer + ); } export async function invokeCommitRpc( @@ -224,7 +240,8 @@ export function newPersistentWriteStream( return new PersistentWriteStream( queue, datastoreImpl.connection, - datastoreImpl.credentials, + datastoreImpl.authCredentials, + datastoreImpl.appCheckCredentials, datastoreImpl.serializer, listener ); @@ -240,7 +257,8 @@ export function newPersistentWatchStream( return new PersistentListenStream( queue, datastoreImpl.connection, - datastoreImpl.credentials, + datastoreImpl.authCredentials, + datastoreImpl.appCheckCredentials, datastoreImpl.serializer, listener ); diff --git a/packages/firestore/src/remote/persistent_stream.ts b/packages/firestore/src/remote/persistent_stream.ts index 3a684e8c543..b7f657876b4 100644 --- a/packages/firestore/src/remote/persistent_stream.ts +++ b/packages/firestore/src/remote/persistent_stream.ts @@ -16,6 +16,7 @@ */ import { CredentialsProvider, Token } from '../api/credentials'; +import { User } from '../auth/user'; import { SnapshotVersion } from '../core/snapshot_version'; import { TargetId } from '../core/types'; import { TargetData } from '../local/target_data'; @@ -199,7 +200,8 @@ export abstract class PersistentStream< private idleTimerId: TimerId, private healthTimerId: TimerId, protected connection: Connection, - private credentialsProvider: CredentialsProvider, + private authCredentialsProvider: CredentialsProvider, + private appCheckCredentialsProvider: CredentialsProvider, protected listener: ListenerType ) { this.backoff = new ExponentialBackoff(queue, connectionTimerId); @@ -387,7 +389,8 @@ export abstract class PersistentStream< // fail, however. In this case, we should get a Code.UNAUTHENTICATED error // before we received the first message and we need to invalidate the token // to ensure that we fetch a new token. - this.credentialsProvider.invalidateToken(); + this.authCredentialsProvider.invalidateToken(); + this.appCheckCredentialsProvider.invalidateToken(); } // Clean up the underlying stream because we are no longer interested in events. @@ -416,7 +419,8 @@ export abstract class PersistentStream< * connection stream. */ protected abstract startRpc( - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Stream; /** @@ -439,8 +443,11 @@ export abstract class PersistentStream< // TODO(mikelehen): Just use dispatchIfNotClosed, but see TODO below. const closeCount = this.closeCount; - this.credentialsProvider.getToken().then( - token => { + Promise.all([ + this.authCredentialsProvider.getToken(), + this.appCheckCredentialsProvider.getToken() + ]).then( + ([authToken, appCheckToken]) => { // Stream can be stopped while waiting for authentication. // TODO(mikelehen): We really should just use dispatchIfNotClosed // and let this dispatch onto the queue, but that opened a spec test can @@ -449,7 +456,7 @@ export abstract class PersistentStream< // Normally we'd have to schedule the callback on the AsyncQueue. // However, the following calls are safe to be called outside the // AsyncQueue since they don't chain asynchronous calls - this.startStream(token); + this.startStream(authToken, appCheckToken); } }, (error: Error) => { @@ -464,7 +471,10 @@ export abstract class PersistentStream< ); } - private startStream(token: Token | null): void { + private startStream( + authToken: Token | null, + appCheckToken: Token | null + ): void { debugAssert( this.state === PersistentStreamState.Starting, 'Trying to start stream in a non-starting state' @@ -472,7 +482,7 @@ export abstract class PersistentStream< const dispatchIfNotClosed = this.getCloseGuardedDispatcher(this.closeCount); - this.stream = this.startRpc(token); + this.stream = this.startRpc(authToken, appCheckToken); this.stream.onOpen(() => { dispatchIfNotClosed(() => { debugAssert( @@ -597,7 +607,8 @@ export class PersistentListenStream extends PersistentStream< constructor( queue: AsyncQueue, connection: Connection, - credentials: CredentialsProvider, + authCredentials: CredentialsProvider, + appCheckCredentials: CredentialsProvider, private serializer: JsonProtoSerializer, listener: WatchStreamListener ) { @@ -607,17 +618,20 @@ export class PersistentListenStream extends PersistentStream< TimerId.ListenStreamIdle, TimerId.HealthCheckTimeout, connection, - credentials, + authCredentials, + appCheckCredentials, listener ); } protected startRpc( - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Stream { return this.connection.openStream( 'Listen', - token + authToken, + appCheckToken ); } @@ -706,7 +720,8 @@ export class PersistentWriteStream extends PersistentStream< constructor( queue: AsyncQueue, connection: Connection, - credentials: CredentialsProvider, + authCredentials: CredentialsProvider, + appCheckCredentials: CredentialsProvider, private serializer: JsonProtoSerializer, listener: WriteStreamListener ) { @@ -716,7 +731,8 @@ export class PersistentWriteStream extends PersistentStream< TimerId.WriteStreamIdle, TimerId.HealthCheckTimeout, connection, - credentials, + authCredentials, + appCheckCredentials, listener ); } @@ -753,11 +769,13 @@ export class PersistentWriteStream extends PersistentStream< } protected startRpc( - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Stream { return this.connection.openStream( 'Write', - token + authToken, + appCheckToken ); } diff --git a/packages/firestore/src/remote/rest_connection.ts b/packages/firestore/src/remote/rest_connection.ts index 2f4b75e192b..524e52c385a 100644 --- a/packages/firestore/src/remote/rest_connection.ts +++ b/packages/firestore/src/remote/rest_connection.ts @@ -70,13 +70,14 @@ export abstract class RestConnection implements Connection { rpcName: string, path: string, req: Req, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Promise { const url = this.makeUrl(rpcName, path); logDebug(LOG_TAG, 'Sending: ', url, req); const headers = {}; - this.modifyHeadersForRequest(headers, token); + this.modifyHeadersForRequest(headers, authToken, appCheckToken); return this.performRPCRequest(rpcName, url, headers, req).then( response => { @@ -102,16 +103,24 @@ export abstract class RestConnection implements Connection { rpcName: string, path: string, request: Req, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Promise { // The REST API automatically aggregates all of the streamed results, so we // can just use the normal invoke() method. - return this.invokeRPC(rpcName, path, request, token); + return this.invokeRPC( + rpcName, + path, + request, + authToken, + appCheckToken + ); } abstract openStream( rpcName: string, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Stream; /** @@ -120,7 +129,8 @@ export abstract class RestConnection implements Connection { */ protected modifyHeadersForRequest( headers: StringMap, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): void { headers['X-Goog-Api-Client'] = getGoogApiClientValue(); @@ -133,12 +143,12 @@ export abstract class RestConnection implements Connection { if (this.databaseInfo.appId) { headers['X-Firebase-GMPID'] = this.databaseInfo.appId; } - if (token) { - for (const header in token.authHeaders) { - if (token.authHeaders.hasOwnProperty(header)) { - headers[header] = token.authHeaders[header]; - } - } + + if (authToken) { + authToken.headers.forEach((value, key) => (headers[key] = value)); + } + if (appCheckToken) { + appCheckToken.headers.forEach((value, key) => (headers[key] = value)); } } diff --git a/packages/firestore/test/integration/browser/webchannel.test.ts b/packages/firestore/test/integration/browser/webchannel.test.ts index 67656d4d34f..be181bf8dc9 100644 --- a/packages/firestore/test/integration/browser/webchannel.test.ts +++ b/packages/firestore/test/integration/browser/webchannel.test.ts @@ -39,6 +39,7 @@ describeFn('WebChannel', () => { const conn = new WebChannelConnection(info); const stream = conn.openStream( 'Listen', + null, null ); diff --git a/packages/firestore/test/integration/remote/stream.test.ts b/packages/firestore/test/integration/remote/stream.test.ts index 2793872287b..6726f92a772 100644 --- a/packages/firestore/test/integration/remote/stream.test.ts +++ b/packages/firestore/test/integration/remote/stream.test.ts @@ -17,7 +17,10 @@ import { expect } from 'chai'; -import { EmptyCredentialsProvider, Token } from '../../../src/api/credentials'; +import { + EmptyAuthCredentialsProvider, + Token +} from '../../../src/api/credentials'; import { SnapshotVersion } from '../../../src/core/snapshot_version'; import { MutationResult } from '../../../src/model/mutation'; import { @@ -147,7 +150,7 @@ describe('Watch Stream', () => { }); }); -class MockCredentialsProvider extends EmptyCredentialsProvider { +class MockAuthCredentialsProvider extends EmptyAuthCredentialsProvider { private states: string[] = []; get observedStates(): string[] { @@ -240,7 +243,7 @@ describe('Write Stream', () => { }); it('force refreshes auth token on receiving unauthenticated error', () => { - const credentials = new MockCredentialsProvider(); + const credentials = new MockAuthCredentialsProvider(); return withTestWriteStream(async (writeStream, streamListener) => { await streamListener.awaitCallback('open'); @@ -275,7 +278,7 @@ describe('Write Stream', () => { }); it('token is not invalidated once the stream is healthy', () => { - const credentials = new MockCredentialsProvider(); + const credentials = new MockAuthCredentialsProvider(); return withTestWriteStream(async (writeStream, streamListener, queue) => { await streamListener.awaitCallback('open'); @@ -298,7 +301,7 @@ export async function withTestWriteStream( streamListener: StreamStatusListener, queue: AsyncQueueImpl ) => Promise, - credentialsProvider = new EmptyCredentialsProvider() + credentialsProvider = new EmptyAuthCredentialsProvider() ): Promise { await withTestDatastore(async datastore => { const queue = newAsyncQueue() as AsyncQueueImpl; diff --git a/packages/firestore/test/integration/util/internal_helpers.ts b/packages/firestore/test/integration/util/internal_helpers.ts index 5c2962dec1f..8548241b7bf 100644 --- a/packages/firestore/test/integration/util/internal_helpers.ts +++ b/packages/firestore/test/integration/util/internal_helpers.ts @@ -21,7 +21,8 @@ import { Firestore } from '../../../compat/api/database'; import { CredentialChangeListener, CredentialsProvider, - EmptyCredentialsProvider + EmptyAppCheckTokenProvider, + EmptyAuthCredentialsProvider } from '../../../src/api/credentials'; import { User } from '../../../src/auth/user'; import { DatabaseId, DatabaseInfo } from '../../../src/core/database_info'; @@ -56,24 +57,33 @@ export function getDefaultDatabaseInfo(): DatabaseInfo { export function withTestDatastore( fn: (datastore: Datastore) => Promise, - credentialsProvider: CredentialsProvider = new EmptyCredentialsProvider() + authCredentialsProvider: CredentialsProvider = new EmptyAuthCredentialsProvider(), + appCheckTokenProvider: CredentialsProvider = new EmptyAppCheckTokenProvider() ): Promise { const databaseInfo = getDefaultDatabaseInfo(); const connection = newConnection(databaseInfo); const serializer = newSerializer(databaseInfo.databaseId); - const datastore = newDatastore(credentialsProvider, connection, serializer); + const datastore = newDatastore( + authCredentialsProvider, + appCheckTokenProvider, + connection, + serializer + ); return fn(datastore); } -export class MockCredentialsProvider extends EmptyCredentialsProvider { - private listener: CredentialChangeListener | null = null; +export class MockAuthCredentialsProvider extends EmptyAuthCredentialsProvider { + private listener: CredentialChangeListener | null = null; private asyncQueue: AsyncQueue | null = null; triggerUserChange(newUser: User): void { this.asyncQueue!.enqueueRetryable(async () => this.listener!(newUser)); } - start(asyncQueue: AsyncQueue, listener: CredentialChangeListener): void { + start( + asyncQueue: AsyncQueue, + listener: CredentialChangeListener + ): void { super.start(asyncQueue, listener); this.asyncQueue = asyncQueue; this.listener = listener; @@ -84,10 +94,10 @@ export function withMockCredentialProviderTestDb( persistence: boolean, fn: ( db: firestore.FirebaseFirestore, - mockCredential: MockCredentialsProvider + mockCredential: MockAuthCredentialsProvider ) => Promise ): Promise { - const mockCredentialsProvider = new MockCredentialsProvider(); + const mockCredentialsProvider = new MockAuthCredentialsProvider(); const settings = { ...DEFAULT_SETTINGS, credentials: { client: mockCredentialsProvider, type: 'provider' } diff --git a/packages/firestore/test/unit/api/database.test.ts b/packages/firestore/test/unit/api/database.test.ts index 8ffec48399a..6e14c588f74 100644 --- a/packages/firestore/test/unit/api/database.test.ts +++ b/packages/firestore/test/unit/api/database.test.ts @@ -17,7 +17,7 @@ import { expect } from 'chai'; -import { EmulatorCredentialsProvider } from '../../../src/api/credentials'; +import { EmulatorAuthCredentialsProvider } from '../../../src/api/credentials'; import { User } from '../../../src/auth/user'; import { collectionReference, @@ -259,11 +259,11 @@ describe('Settings', () => { const mockUserToken = { sub: 'foobar' }; db.useEmulator('localhost', 9000, { mockUserToken }); - const credentials = db._delegate._credentials; - expect(credentials).to.be.instanceOf(EmulatorCredentialsProvider); + const credentials = db._delegate._authCredentials; + expect(credentials).to.be.instanceOf(EmulatorAuthCredentialsProvider); const token = await credentials.getToken(); expect(token!.type).to.eql('OAuth'); - expect(token!.user.uid).to.eql(mockUserToken.sub); + expect(token!.user!.uid).to.eql(mockUserToken.sub); }); it('sets credentials based on mockUserToken string', async () => { @@ -273,8 +273,8 @@ describe('Settings', () => { mockUserToken: 'my-custom-mock-user-token' }); - const credentials = db._delegate._credentials; - expect(credentials).to.be.instanceOf(EmulatorCredentialsProvider); + const credentials = db._delegate._authCredentials; + expect(credentials).to.be.instanceOf(EmulatorAuthCredentialsProvider); const token = await credentials.getToken(); expect(token!.type).to.eql('OAuth'); expect(token!.user).to.eql(User.MOCK_USER); diff --git a/packages/firestore/test/unit/remote/datastore.test.ts b/packages/firestore/test/unit/remote/datastore.test.ts index 45e0dbf8843..46729f1c5a8 100644 --- a/packages/firestore/test/unit/remote/datastore.test.ts +++ b/packages/firestore/test/unit/remote/datastore.test.ts @@ -18,7 +18,11 @@ import { expect, use } from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; -import { EmptyCredentialsProvider, Token } from '../../../src/api/credentials'; +import { + EmptyAppCheckTokenProvider, + EmptyAuthCredentialsProvider, + Token +} from '../../../src/api/credentials'; import { DatabaseId } from '../../../src/core/database_info'; import { Connection, Stream } from '../../../src/remote/connection'; import { @@ -63,7 +67,14 @@ describe('Datastore', () => { } } - class MockCredentialsProvider extends EmptyCredentialsProvider { + class MockAuthCredentialsProvider extends EmptyAuthCredentialsProvider { + invalidateTokenInvoked = false; + invalidateToken(): void { + this.invalidateTokenInvoked = true; + } + } + + class MockAppCheckTokenProvider extends EmptyAppCheckTokenProvider { invalidateTokenInvoked = false; invalidateToken(): void { this.invalidateTokenInvoked = true; @@ -94,7 +105,8 @@ describe('Datastore', () => { it('newDatastore() returns an an instance of Datastore', () => { const datastore = newDatastore( - new EmptyCredentialsProvider(), + new EmptyAuthCredentialsProvider(), + new EmptyAppCheckTokenProvider(), new MockConnection(), serializer ); @@ -103,7 +115,8 @@ describe('Datastore', () => { it('DatastoreImpl.invokeRPC() fails if terminated', async () => { const datastore = newDatastore( - new EmptyCredentialsProvider(), + new EmptyAuthCredentialsProvider(), + new EmptyAppCheckTokenProvider(), new MockConnection(), serializer ); @@ -120,49 +133,71 @@ describe('Datastore', () => { const connection = new MockConnection(); connection.invokeRPC = () => Promise.reject(new FirestoreError(Code.ABORTED, 'zzyzx')); - const credentials = new MockCredentialsProvider(); - const datastore = newDatastore(credentials, connection, serializer); + const authCredentials = new MockAuthCredentialsProvider(); + const appCheckCredentials = new MockAppCheckTokenProvider(); + const datastore = newDatastore( + authCredentials, + appCheckCredentials, + connection, + serializer + ); await expect(invokeDatastoreImplInvokeRpc(datastore)) .to.eventually.be.rejectedWith('zzyzx') .and.include({ 'name': 'FirebaseError', 'code': Code.ABORTED }); - expect(credentials.invalidateTokenInvoked).to.be.false; + expect(authCredentials.invalidateTokenInvoked).to.be.false; + expect(appCheckCredentials.invalidateTokenInvoked).to.be.false; }); it('DatastoreImpl.invokeRPC() wraps unknown exceptions in a FirestoreError', async () => { const connection = new MockConnection(); connection.invokeRPC = () => Promise.reject('zzyzx'); - const credentials = new MockCredentialsProvider(); - const datastore = newDatastore(credentials, connection, serializer); + const authCredentials = new MockAuthCredentialsProvider(); + const appCheckCredentials = new MockAppCheckTokenProvider(); + const datastore = newDatastore( + authCredentials, + appCheckCredentials, + connection, + serializer + ); await expect(invokeDatastoreImplInvokeRpc(datastore)) .to.eventually.be.rejectedWith('zzyzx') .and.include({ 'name': 'FirebaseError', 'code': Code.UNKNOWN }); - expect(credentials.invalidateTokenInvoked).to.be.false; + expect(authCredentials.invalidateTokenInvoked).to.be.false; + expect(appCheckCredentials.invalidateTokenInvoked).to.be.false; }); it('DatastoreImpl.invokeRPC() invalidates the token if unauthenticated', async () => { const connection = new MockConnection(); connection.invokeRPC = () => Promise.reject(new FirestoreError(Code.UNAUTHENTICATED, 'zzyzx')); - const credentials = new MockCredentialsProvider(); - const datastore = newDatastore(credentials, connection, serializer); + const authCredentials = new MockAuthCredentialsProvider(); + const appCheckCredentials = new MockAppCheckTokenProvider(); + const datastore = newDatastore( + authCredentials, + appCheckCredentials, + connection, + serializer + ); await expect(invokeDatastoreImplInvokeRpc(datastore)) .to.eventually.be.rejectedWith('zzyzx') .and.include({ 'name': 'FirebaseError', 'code': Code.UNAUTHENTICATED }); - expect(credentials.invalidateTokenInvoked).to.be.true; + expect(authCredentials.invalidateTokenInvoked).to.be.true; + expect(appCheckCredentials.invalidateTokenInvoked).to.be.true; }); it('DatastoreImpl.invokeStreamingRPC() fails if terminated', async () => { const datastore = newDatastore( - new EmptyCredentialsProvider(), + new EmptyAuthCredentialsProvider(), + new EmptyAppCheckTokenProvider(), new MockConnection(), serializer ); @@ -179,43 +214,64 @@ describe('Datastore', () => { const connection = new MockConnection(); connection.invokeStreamingRPC = () => Promise.reject(new FirestoreError(Code.ABORTED, 'zzyzx')); - const credentials = new MockCredentialsProvider(); - const datastore = newDatastore(credentials, connection, serializer); + const authCredentials = new MockAuthCredentialsProvider(); + const appCheckCredentials = new MockAppCheckTokenProvider(); + const datastore = newDatastore( + authCredentials, + appCheckCredentials, + connection, + serializer + ); await expect(invokeDatastoreImplInvokeStreamingRPC(datastore)) .to.eventually.be.rejectedWith('zzyzx') .and.include({ 'name': 'FirebaseError', 'code': Code.ABORTED }); - expect(credentials.invalidateTokenInvoked).to.be.false; + expect(authCredentials.invalidateTokenInvoked).to.be.false; + expect(appCheckCredentials.invalidateTokenInvoked).to.be.false; }); it('DatastoreImpl.invokeStreamingRPC() wraps unknown exceptions in a FirestoreError', async () => { const connection = new MockConnection(); connection.invokeStreamingRPC = () => Promise.reject('zzyzx'); - const credentials = new MockCredentialsProvider(); - const datastore = newDatastore(credentials, connection, serializer); + const authCredentials = new MockAuthCredentialsProvider(); + const appCheckCredentials = new MockAppCheckTokenProvider(); + const datastore = newDatastore( + authCredentials, + appCheckCredentials, + connection, + serializer + ); await expect(invokeDatastoreImplInvokeStreamingRPC(datastore)) .to.eventually.be.rejectedWith('zzyzx') .and.include({ 'name': 'FirebaseError', 'code': Code.UNKNOWN }); - expect(credentials.invalidateTokenInvoked).to.be.false; + expect(authCredentials.invalidateTokenInvoked).to.be.false; + expect(appCheckCredentials.invalidateTokenInvoked).to.be.false; }); it('DatastoreImpl.invokeStreamingRPC() invalidates the token if unauthenticated', async () => { const connection = new MockConnection(); connection.invokeStreamingRPC = () => Promise.reject(new FirestoreError(Code.UNAUTHENTICATED, 'zzyzx')); - const credentials = new MockCredentialsProvider(); - const datastore = newDatastore(credentials, connection, serializer); + const authCredentials = new MockAuthCredentialsProvider(); + const appCheckCredentials = new MockAppCheckTokenProvider(); + const datastore = newDatastore( + authCredentials, + appCheckCredentials, + connection, + serializer + ); await expect(invokeDatastoreImplInvokeStreamingRPC(datastore)) .to.eventually.be.rejectedWith('zzyzx') .and.include({ 'name': 'FirebaseError', 'code': Code.UNAUTHENTICATED }); - expect(credentials.invalidateTokenInvoked).to.be.true; + expect(authCredentials.invalidateTokenInvoked).to.be.true; + expect(appCheckCredentials.invalidateTokenInvoked).to.be.true; }); }); diff --git a/packages/firestore/test/unit/remote/rest_connection.test.ts b/packages/firestore/test/unit/remote/rest_connection.test.ts index 626ac35ac1e..8266ce74cf2 100644 --- a/packages/firestore/test/unit/remote/rest_connection.test.ts +++ b/packages/firestore/test/unit/remote/rest_connection.test.ts @@ -17,7 +17,7 @@ import { expect } from 'chai'; -import { Token } from '../../../src/api/credentials'; +import { AppCheckToken, OAuthToken, Token } from '../../../src/api/credentials'; import { User } from '../../../src/auth/user'; import { DatabaseId, DatabaseInfo } from '../../../src/core/database_info'; import { SDK_VERSION } from '../../../src/core/version'; @@ -35,7 +35,8 @@ export class TestRestConnection extends RestConnection { openStream( rpcName: string, - token: Token | null + authToken: Token | null, + appCheckToken: Token | null ): Stream { throw new Error('Not Implemented'); } @@ -73,6 +74,7 @@ describe('RestConnection', () => { 'Commit', 'projects/testproject/databases/(default)/documents', {}, + null, null ); expect(connection.lastUrl).to.equal( @@ -85,17 +87,31 @@ describe('RestConnection', () => { 'RunQuery', 'projects/testproject/databases/(default)/documents/foo', {}, - { - user: User.UNAUTHENTICATED, - type: 'OAuth', - authHeaders: { 'Authorization': 'Bearer owner' } - } + new OAuthToken('owner', User.UNAUTHENTICATED), + new AppCheckToken('some-app-check-token') ); expect(connection.lastHeaders).to.deep.equal({ 'Authorization': 'Bearer owner', + 'Content-Type': 'text/plain', + 'X-Firebase-GMPID': 'test-app-id', + 'X-Goog-Api-Client': `gl-js/ fire/${SDK_VERSION}`, + 'x-firebase-appcheck': 'some-app-check-token' + }); + }); + + it('empty app check token is not added to headers', async () => { + await connection.invokeRPC( + 'RunQuery', + 'projects/testproject/databases/(default)/documents/foo', + {}, + null, + new AppCheckToken('') + ); + expect(connection.lastHeaders).to.deep.equal({ 'Content-Type': 'text/plain', 'X-Firebase-GMPID': 'test-app-id', 'X-Goog-Api-Client': `gl-js/ fire/${SDK_VERSION}` + // Note: AppCheck token should not exist here. }); }); @@ -105,6 +121,7 @@ describe('RestConnection', () => { 'RunQuery', 'projects/testproject/databases/(default)/documents/coll', {}, + null, null ); expect(response).to.deep.equal({ response: true }); @@ -118,6 +135,7 @@ describe('RestConnection', () => { 'RunQuery', 'projects/testproject/databases/(default)/documents/coll', {}, + null, null ) ).to.be.eventually.rejectedWith(error); diff --git a/packages/firestore/test/unit/specs/spec_test_components.ts b/packages/firestore/test/unit/specs/spec_test_components.ts index e5fe4fa3743..d37407a5850 100644 --- a/packages/firestore/test/unit/specs/spec_test_components.ts +++ b/packages/firestore/test/unit/specs/spec_test_components.ts @@ -135,7 +135,12 @@ export class MockOnlineComponentProvider extends OnlineComponentProvider { cfg.databaseInfo.databaseId, /* useProto3Json= */ true ); - return newDatastore(cfg.credentials, this.connection, serializer); + return newDatastore( + cfg.authCredentials, + cfg.appCheckCredentials, + this.connection, + serializer + ); } } diff --git a/packages/firestore/test/unit/specs/spec_test_runner.ts b/packages/firestore/test/unit/specs/spec_test_runner.ts index 3ba157871dc..8626250046d 100644 --- a/packages/firestore/test/unit/specs/spec_test_runner.ts +++ b/packages/firestore/test/unit/specs/spec_test_runner.ts @@ -18,7 +18,10 @@ import { expect } from 'chai'; import { LoadBundleTask } from '../../../src/api/bundle'; -import { EmptyCredentialsProvider } from '../../../src/api/credentials'; +import { + EmptyAppCheckTokenProvider, + EmptyAuthCredentialsProvider +} from '../../../src/api/credentials'; import { User } from '../../../src/auth/user'; import { ComponentConfiguration } from '../../../src/core/component_provider'; import { DatabaseInfo } from '../../../src/core/database_info'; @@ -286,7 +289,8 @@ abstract class TestRunner { const configuration = { asyncQueue: this.queue, databaseInfo: this.databaseInfo, - credentials: new EmptyCredentialsProvider(), + authCredentials: new EmptyAuthCredentialsProvider(), + appCheckCredentials: new EmptyAppCheckTokenProvider(), clientId: this.clientId, initialUser: this.user, maxConcurrentLimboResolutions: diff --git a/packages/firestore/test/util/api_helpers.ts b/packages/firestore/test/util/api_helpers.ts index 432f3fe4666..374c58abeb2 100644 --- a/packages/firestore/test/util/api_helpers.ts +++ b/packages/firestore/test/util/api_helpers.ts @@ -28,7 +28,10 @@ import { QuerySnapshot, UserDataWriter } from '../../compat/api/database'; -import { EmptyCredentialsProvider } from '../../src/api/credentials'; +import { + EmptyAppCheckTokenProvider, + EmptyAuthCredentialsProvider +} from '../../src/api/credentials'; import { ensureFirestoreConfigured, Firestore as ExpFirestore @@ -69,7 +72,11 @@ export function firestore(): Firestore { export function newTestFirestore(projectId = 'new-project'): Firestore { return new Firestore( new DatabaseId(projectId), - new ExpFirestore(new DatabaseId(projectId), new EmptyCredentialsProvider()), + new ExpFirestore( + new DatabaseId(projectId), + new EmptyAuthCredentialsProvider(), + new EmptyAppCheckTokenProvider() + ), new IndexedDbPersistenceProvider() ); }