Skip to content

Add mockUserToken support for Firestore Emulator. #4837

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 3, 2021
Merged
7 changes: 7 additions & 0 deletions .changeset/lemon-ligers-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@firebase/firestore-types': minor
'@firebase/firestore': minor
'firebase': minor
---

Add mockUserToken support for Firestore.
85 changes: 84 additions & 1 deletion packages/firebase/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<FirebaseIdToken>;
}

export default firebase;
Expand Down
10 changes: 8 additions & 2 deletions packages/firestore-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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<void>;

Expand Down
3 changes: 2 additions & 1 deletion packages/firestore-types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/firestore/externs.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions packages/firestore/src/api/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Token | null> {
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
Expand Down
16 changes: 13 additions & 3 deletions packages/firestore/src/api/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
Expand Down
30 changes: 28 additions & 2 deletions packages/firestore/src/lite/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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))
);
}
}

/**
Expand Down
18 changes: 18 additions & 0 deletions packages/firestore/test/integration/api/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
14 changes: 14 additions & 0 deletions packages/firestore/test/unit/api/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import { expect } from 'chai';

import { EmulatorCredentialsProvider } from '../../../src/api/credentials';
import {
collectionReference,
documentReference,
Expand Down Expand Up @@ -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);
});
});