diff --git a/.changeset/lemon-ligers-protect.md b/.changeset/lemon-ligers-protect.md new file mode 100644 index 00000000000..3efae59f426 --- /dev/null +++ b/.changeset/lemon-ligers-protect.md @@ -0,0 +1,7 @@ +--- +'@firebase/firestore-types': minor +'@firebase/firestore': minor +'firebase': minor +--- + +Add mockUserToken support for Firestore. diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index 45bf03391db..28f51f54314 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -8179,8 +8179,16 @@ declare namespace firebase.firestore { * * @param host the emulator host (ex: localhost). * @param port the emulator port (ex: 9000). + * @param options.mockUserToken - the mock auth token to use for unit + * testing Security Rules. */ - useEmulator(host: string, port: number): void; + useEmulator( + host: string, + port: number, + options?: { + mockUserToken?: EmulatorMockTokenOptions; + } + ): void; /** * Attempts to enable persistent storage, if possible. @@ -9976,6 +9984,81 @@ declare namespace firebase.firestore { name: string; stack?: string; } + + type FirebaseSignInProvider = + | 'custom' + | 'email' + | 'password' + | 'phone' + | 'anonymous' + | 'google.com' + | 'facebook.com' + | 'github.com' + | 'twitter.com' + | 'microsoft.com' + | 'apple.com'; + + interface FirebaseIdToken { + /** Always set to https://securetoken.google.com/PROJECT_ID */ + iss: string; + + /** Always set to PROJECT_ID */ + aud: string; + + /** The user's unique id */ + sub: string; + + /** The token issue time, in seconds since epoch */ + iat: number; + + /** The token expiry time, normally 'iat' + 3600 */ + exp: number; + + /** The user's unique id, must be equal to 'sub' */ + user_id: string; + + /** The time the user authenticated, normally 'iat' */ + auth_time: number; + + /** The sign in provider, only set when the provider is 'anonymous' */ + provider_id?: 'anonymous'; + + /** The user's primary email */ + email?: string; + + /** The user's email verification status */ + email_verified?: boolean; + + /** The user's primary phone number */ + phone_number?: string; + + /** The user's display name */ + name?: string; + + /** The user's profile photo URL */ + picture?: string; + + /** Information on all identities linked to this user */ + firebase: { + /** The primary sign-in provider */ + sign_in_provider: FirebaseSignInProvider; + + /** A map of providers to the user's list of unique identifiers from each provider */ + identities?: { [provider in FirebaseSignInProvider]?: string[] }; + }; + + /** Custom claims set by the developer */ + [claim: string]: unknown; + + // NO LONGER SUPPORTED. Use "sub" instead. (Not a jsdoc comment to avoid generating docs.) + uid?: never; + } + + export type EmulatorMockTokenOptions = ( + | { user_id: string } + | { sub: string } + ) & + Partial; } export default firebase; diff --git a/packages/firestore-types/index.d.ts b/packages/firestore-types/index.d.ts index 13232f20a53..f7a76e32064 100644 --- a/packages/firestore-types/index.d.ts +++ b/packages/firestore-types/index.d.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { FirebaseApp, FirebaseNamespace } from '@firebase/app-types'; +import { EmulatorMockTokenOptions } from '@firebase/util'; export type DocumentData = { [field: string]: any }; @@ -61,7 +61,13 @@ export class FirebaseFirestore { settings(settings: Settings): void; - useEmulator(host: string, port: number): void; + useEmulator( + host: string, + port: number, + options?: { + mockUserToken?: EmulatorMockTokenOptions; + } + ): void; enablePersistence(settings?: PersistenceSettings): Promise; diff --git a/packages/firestore-types/package.json b/packages/firestore-types/package.json index b73bcf87f05..ba4a780ad57 100644 --- a/packages/firestore-types/package.json +++ b/packages/firestore-types/package.json @@ -12,7 +12,8 @@ "index.d.ts" ], "peerDependencies": { - "@firebase/app-types": "0.x" + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" }, "repository": { "directory": "packages/firestore-types", diff --git a/packages/firestore/externs.json b/packages/firestore/externs.json index de3847a83f9..5b9cb8cda84 100644 --- a/packages/firestore/externs.json +++ b/packages/firestore/externs.json @@ -1,5 +1,5 @@ { - "externs" : [ + "externs": [ "node_modules/@types/node/base.d.ts", "node_modules/@types/node/globals.d.ts", "node_modules/typescript/lib/lib.es5.d.ts", @@ -26,6 +26,7 @@ "packages/logger/dist/src/logger.d.ts", "packages/webchannel-wrapper/src/index.d.ts", "packages/util/dist/src/crypt.d.ts", + "packages/util/dist/src/emulator.d.ts", "packages/util/dist/src/environment.d.ts", "packages/util/dist/src/compat.d.ts", "packages/firestore/export.ts", diff --git a/packages/firestore/src/api/credentials.ts b/packages/firestore/src/api/credentials.ts index ca1b0006ba2..69d483a8f1d 100644 --- a/packages/firestore/src/api/credentials.ts +++ b/packages/firestore/src/api/credentials.ts @@ -135,6 +135,41 @@ export class EmptyCredentialsProvider implements CredentialsProvider { } } +/** + * A CredentialsProvider that always returns a constant token. Used for + * emulator token mocking. + */ +export class EmulatorCredentialsProvider implements CredentialsProvider { + constructor(private token: Token) {} + + /** + * Stores the listener registered with setChangeListener() + * This isn't actually necessary since the UID never changes, but we use this + * to verify the listen contract is adhered to in tests. + */ + private changeListener: CredentialChangeListener | null = null; + + getToken(): Promise { + return Promise.resolve(this.token); + } + + invalidateToken(): void {} + + setChangeListener(changeListener: CredentialChangeListener): void { + debugAssert( + !this.changeListener, + 'Can only call setChangeListener() once.' + ); + this.changeListener = changeListener; + // Fire with initial user. + changeListener(this.token.user); + } + + removeChangeListener(): void { + this.changeListener = null; + } +} + export class FirebaseCredentialsProvider implements CredentialsProvider { /** * The auth token listener registered with FirebaseApp, retained here so we diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index e7e571ce21f..9b389d51bed 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -44,7 +44,11 @@ import { WhereFilterOp as PublicWhereFilterOp, WriteBatch as PublicWriteBatch } from '@firebase/firestore-types'; -import { Compat, getModularInstance } from '@firebase/util'; +import { + Compat, + EmulatorMockTokenOptions, + getModularInstance +} from '@firebase/util'; import { LoadBundleTask, @@ -223,8 +227,14 @@ export class Firestore this._delegate._setSettings(settingsLiteral); } - useEmulator(host: string, port: number): void { - useFirestoreEmulator(this._delegate, host, port); + useEmulator( + host: string, + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions; + } = {} + ): void { + useFirestoreEmulator(this._delegate, host, port, options); } enableNetwork(): Promise { diff --git a/packages/firestore/src/lite/database.ts b/packages/firestore/src/lite/database.ts index 6b510530d6b..43730105de5 100644 --- a/packages/firestore/src/lite/database.ts +++ b/packages/firestore/src/lite/database.ts @@ -24,13 +24,17 @@ import { } from '@firebase/app-exp'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; +import { createMockUserToken, EmulatorMockTokenOptions } from '@firebase/util'; import { CredentialsProvider, EmptyCredentialsProvider, + EmulatorCredentialsProvider, FirebaseCredentialsProvider, - makeCredentialsProvider + makeCredentialsProvider, + OAuthToken } from '../api/credentials'; +import { User } from '../auth/user'; import { DatabaseId } from '../core/database_info'; import { Code, FirestoreError } from '../util/error'; import { cast } from '../util/input_validation'; @@ -226,11 +230,16 @@ export function getFirestore(app: FirebaseApp = getApp()): FirebaseFirestore { * emulator. * @param host - the emulator host (ex: localhost). * @param port - the emulator port (ex: 9000). + * @param options.mockUserToken - the mock auth token to use for unit testing + * Security Rules. */ export function useFirestoreEmulator( firestore: FirebaseFirestore, host: string, - port: number + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions; + } = {} ): void { firestore = cast(firestore, FirebaseFirestore); const settings = firestore._getSettings(); @@ -247,6 +256,23 @@ export function useFirestoreEmulator( host: `${host}:${port}`, ssl: false }); + + if (options.mockUserToken) { + // Let createMockUserToken validate first (catches common mistakes like + // invalid field "uid" and missing field "sub" / "user_id".) + const token = createMockUserToken(options.mockUserToken); + const uid = options.mockUserToken.sub || options.mockUserToken.user_id; + if (!uid) { + throw new FirestoreError( + Code.INVALID_ARGUMENT, + "mockUserToken must contain 'sub' or 'user_id' field!" + ); + } + + firestore._credentials = new EmulatorCredentialsProvider( + new OAuthToken(token, new User(uid)) + ); + } } /** diff --git a/packages/firestore/test/integration/api/validation.test.ts b/packages/firestore/test/integration/api/validation.test.ts index ec56d625bee..b69f309cf17 100644 --- a/packages/firestore/test/integration/api/validation.test.ts +++ b/packages/firestore/test/integration/api/validation.test.ts @@ -157,6 +157,24 @@ apiDescribe('Validation:', (persistence: boolean) => { expect(() => db.useEmulator('localhost', 9000)).to.throw(errorMsg); } ); + + validationIt(persistence, 'useEmulator can set mockUserToken', () => { + const db = newTestFirestore('test-project'); + // Verify that this doesn't throw. + db.useEmulator('localhost', 9000, { mockUserToken: { sub: 'foo' } }); + }); + + validationIt( + persistence, + 'throws if sub / user_id is missing in mockUserToken', + async db => { + const errorMsg = "mockUserToken must contain 'sub' or 'user_id' field!"; + + expect(() => + db.useEmulator('localhost', 9000, { mockUserToken: {} as any }) + ).to.throw(errorMsg); + } + ); }); describe('Firestore', () => { diff --git a/packages/firestore/test/unit/api/database.test.ts b/packages/firestore/test/unit/api/database.test.ts index f01c5d509eb..8ba1d406a6d 100644 --- a/packages/firestore/test/unit/api/database.test.ts +++ b/packages/firestore/test/unit/api/database.test.ts @@ -17,6 +17,7 @@ import { expect } from 'chai'; +import { EmulatorCredentialsProvider } from '../../../src/api/credentials'; import { collectionReference, documentReference, @@ -250,4 +251,17 @@ describe('Settings', () => { expect(db._delegate._getSettings().host).to.equal('localhost:9000'); expect(db._delegate._getSettings().ssl).to.be.false; }); + + it('sets credentials based on mockUserToken', async () => { + // Use a new instance of Firestore in order to configure settings. + const db = newTestFirestore(); + const mockUserToken = { sub: 'foobar' }; + db.useEmulator('localhost', 9000, { mockUserToken }); + + const credentials = db._delegate._credentials; + expect(credentials).to.be.instanceOf(EmulatorCredentialsProvider); + const token = await credentials.getToken(); + expect(token!.type).to.eql('OAuth'); + expect(token!.user.uid).to.eql(mockUserToken.sub); + }); });