Skip to content

Commit

Permalink
Add mockUserToken support for database emulator. (#4792)
Browse files Browse the repository at this point in the history
* Add mockUserToken support for database emulator.

* Add compact API.

* Add generated API file.

* Change default project ID to demo-project.

* Fix db.useEmulator

* Create sweet-monkeys-warn.md

* Update packages/database/src/exp/Database.ts

Co-authored-by: Sebastian Schmidt <mrschmidt@google.com>

* Update packages/util/test/emulator.test.ts

Co-authored-by: Sebastian Schmidt <mrschmidt@google.com>

* Fix sub field name.

* Remove optional in jsdocs.

* Create loud-feet-jump.md

* Update loud-feet-jump.md

* Make sub/user_id required in typing.

* Update error messages to contain mockUserToken.

* Add API changes in md.

* Update error message for uid field.

* Update loud-feet-jump.md

* Change custom claim typing to unknown.

Co-authored-by: Sebastian Schmidt <mrschmidt@google.com>
  • Loading branch information
yuchenshi and schmidt-sebastian committed Apr 29, 2021
1 parent 16102f0 commit ac4ad08
Show file tree
Hide file tree
Showing 9 changed files with 271 additions and 18 deletions.
7 changes: 7 additions & 0 deletions .changeset/loud-feet-jump.md
@@ -0,0 +1,7 @@
---
"@firebase/database": minor
"firebase": minor
"@firebase/util": minor
---

Add mockUserToken support for database emulator.
7 changes: 5 additions & 2 deletions common/api-review/database.api.md
Expand Up @@ -4,9 +4,10 @@
```ts

import { EmulatorMockTokenOptions } from '@firebase/util';
import { FirebaseApp } from '@firebase/app';

// @public (undocumented)
// @public
export function child(parent: Reference, path: string): Reference;

// @public
Expand Down Expand Up @@ -229,7 +230,9 @@ export type Unsubscribe = () => void;
export function update(ref: Reference, values: object): Promise<void>;

// @public
export function useDatabaseEmulator(db: FirebaseDatabase, host: string, port: number): void;
export function useDatabaseEmulator(db: FirebaseDatabase, host: string, port: number, options?: {
mockUserToken?: EmulatorMockTokenOptions;
}): void;


```
17 changes: 14 additions & 3 deletions packages/database/src/api/Database.ts
Expand Up @@ -18,7 +18,11 @@

import { FirebaseApp } from '@firebase/app-types';
import { FirebaseService } from '@firebase/app-types/private';
import { validateArgCount, Compat } from '@firebase/util';
import {
validateArgCount,
Compat,
EmulatorMockTokenOptions
} from '@firebase/util';

import {
FirebaseDatabase as ExpDatabase,
Expand Down Expand Up @@ -58,9 +62,16 @@ export class Database implements FirebaseService, Compat<ExpDatabase> {
*
* @param host - the emulator host (ex: localhost)
* @param port - the emulator port (ex: 8080)
* @param options.mockUserToken - the mock auth token to use for unit testing Security Rules
*/
useEmulator(host: string, port: number): void {
useDatabaseEmulator(this._delegate, host, port);
useEmulator(
host: string,
port: number,
options: {
mockUserToken?: EmulatorMockTokenOptions;
} = {}
): void {
useDatabaseEmulator(this._delegate, host, port, options);
}

/**
Expand Down
13 changes: 8 additions & 5 deletions packages/database/src/core/AuthTokenProvider.ts
Expand Up @@ -109,20 +109,23 @@ export class FirebaseAuthTokenProvider implements AuthTokenProvider {
}
}

/* Auth token provider that the Admin SDK uses to connect to the Emulator. */
export class EmulatorAdminTokenProvider implements AuthTokenProvider {
private static EMULATOR_AUTH_TOKEN = 'owner';
/* AuthTokenProvider that supplies a constant token. Used by Admin SDK or mockUserToken with emulators. */
export class EmulatorTokenProvider implements AuthTokenProvider {
/** A string that is treated as an admin access token by the RTDB emulator. Used by Admin SDK. */
static OWNER = 'owner';

constructor(private accessToken: string) {}

getToken(forceRefresh: boolean): Promise<FirebaseAuthTokenData> {
return Promise.resolve({
accessToken: EmulatorAdminTokenProvider.EMULATOR_AUTH_TOKEN
accessToken: this.accessToken
});
}

addTokenChangeListener(listener: (token: string | null) => void): void {
// Invoke the listener immediately to match the behavior in Firebase Auth
// (see packages/auth/src/auth.js#L1807)
listener(EmulatorAdminTokenProvider.EMULATOR_AUTH_TOKEN);
listener(this.accessToken);
}

removeTokenChangeListener(listener: (token: string | null) => void): void {}
Expand Down
43 changes: 35 additions & 8 deletions packages/database/src/exp/Database.ts
Expand Up @@ -25,12 +25,16 @@ import {
} from '@firebase/app-exp';
import { FirebaseAuthInternalName } from '@firebase/auth-interop-types';
import { Provider } from '@firebase/component';
import { getModularInstance } from '@firebase/util';
import {
getModularInstance,
createMockUserToken,
EmulatorMockTokenOptions
} from '@firebase/util';

import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider';
import {
AuthTokenProvider,
EmulatorAdminTokenProvider,
EmulatorTokenProvider,
FirebaseAuthTokenProvider
} from '../core/AuthTokenProvider';
import { Repo, repoInterrupt, repoResume, repoStart } from '../core/Repo';
Expand Down Expand Up @@ -76,7 +80,8 @@ let useRestClient = false;
function repoManagerApplyEmulatorSettings(
repo: Repo,
host: string,
port: number
port: number,
tokenProvider?: AuthTokenProvider
): void {
repo.repoInfo_ = new RepoInfo(
`${host}:${port}`,
Expand All @@ -88,8 +93,8 @@ function repoManagerApplyEmulatorSettings(
repo.repoInfo_.includeNamespaceInQueryParams
);

if (repo.repoInfo_.nodeAdmin) {
repo.authTokenProvider_ = new EmulatorAdminTokenProvider();
if (tokenProvider) {
repo.authTokenProvider_ = tokenProvider;
}
}

Expand Down Expand Up @@ -138,7 +143,7 @@ export function repoManagerDatabaseFromApp(

const authTokenProvider =
nodeAdmin && isEmulator
? new EmulatorAdminTokenProvider()
? new EmulatorTokenProvider(EmulatorTokenProvider.OWNER)
: new FirebaseAuthTokenProvider(app.name, app.options, authProvider);

validateUrl('Invalid Firebase Database URL', parsedUrl);
Expand Down Expand Up @@ -295,11 +300,15 @@ export function getDatabase(
* @param db - The instance to modify.
* @param host - The emulator host (ex: localhost)
* @param port - The emulator port (ex: 8080)
* @param options.mockUserToken - the mock auth token to use for unit testing Security Rules
*/
export function useDatabaseEmulator(
db: FirebaseDatabase,
host: string,
port: number
port: number,
options: {
mockUserToken?: EmulatorMockTokenOptions;
} = {}
): void {
db = getModularInstance(db);
db._checkNotDeleted('useEmulator');
Expand All @@ -308,8 +317,26 @@ export function useDatabaseEmulator(
'Cannot call useEmulator() after instance has already been initialized.'
);
}

const repo = db._repoInternal;
let tokenProvider: EmulatorTokenProvider | undefined = undefined;
if (repo.repoInfo_.nodeAdmin) {
if (options.mockUserToken) {
fatal(
'mockUserToken is not supported by the Admin SDK. For client access with mock users, please use the "firebase" package instead of "firebase-admin".'
);
}
tokenProvider = new EmulatorTokenProvider(EmulatorTokenProvider.OWNER);
} else if (options.mockUserToken) {
const token = createMockUserToken(
options.mockUserToken,
db.app.options.projectId
);
tokenProvider = new EmulatorTokenProvider(token);
}

// Modify the repo to apply emulator settings
repoManagerApplyEmulatorSettings(db._repoInternal, host, port);
repoManagerApplyEmulatorSettings(repo, host, port, tokenProvider);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/util/index.node.ts
Expand Up @@ -25,6 +25,7 @@ export * from './src/crypt';
export * from './src/constants';
export * from './src/deepCopy';
export * from './src/deferred';
export * from './src/emulator';
export * from './src/environment';
export * from './src/errors';
export * from './src/json';
Expand Down
1 change: 1 addition & 0 deletions packages/util/index.ts
Expand Up @@ -20,6 +20,7 @@ export * from './src/crypt';
export * from './src/constants';
export * from './src/deepCopy';
export * from './src/deferred';
export * from './src/emulator';
export * from './src/environment';
export * from './src/errors';
export * from './src/json';
Expand Down
142 changes: 142 additions & 0 deletions packages/util/src/emulator.ts
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { base64 } from './crypt';

// Firebase Auth tokens contain snake_case claims following the JWT standard / convention.
/* eslint-disable camelcase */

export type FirebaseSignInProvider =
| 'custom'
| 'email'
| 'password'
| 'phone'
| 'anonymous'
| 'google.com'
| 'facebook.com'
| 'github.com'
| 'twitter.com'
| 'microsoft.com'
| 'apple.com';

interface FirebaseIdToken {
// Always set to https://securetoken.google.com/PROJECT_ID
iss: string;

// Always set to PROJECT_ID
aud: string;

// The user's unique id
sub: string;

// The token issue time, in seconds since epoch
iat: number;

// The token expiry time, normally 'iat' + 3600
exp: number;

// The user's unique id, must be equal to 'sub'
user_id: string;

// The time the user authenticated, normally 'iat'
auth_time: number;

// The sign in provider, only set when the provider is 'anonymous'
provider_id?: 'anonymous';

// The user's primary email
email?: string;

// The user's email verification status
email_verified?: boolean;

// The user's primary phone number
phone_number?: string;

// The user's display name
name?: string;

// The user's profile photo URL
picture?: string;

// Information on all identities linked to this user
firebase: {
// The primary sign-in provider
sign_in_provider: FirebaseSignInProvider;

// A map of providers to the user's list of unique identifiers from
// each provider
identities?: { [provider in FirebaseSignInProvider]?: string[] };
};

// Custom claims set by the developer
[claim: string]: unknown;

uid?: never; // Try to catch a common mistake of "uid" (should be "sub" instead).
}

export type EmulatorMockTokenOptions = ({ user_id: string } | { sub: string }) &
Partial<FirebaseIdToken>;

export function createMockUserToken(
token: EmulatorMockTokenOptions,
projectId?: string
): string {
if (token.uid) {
throw new Error(
'The "uid" field is no longer supported by mockUserToken. Please use "sub" instead for Firebase Auth User ID.'
);
}
// Unsecured JWTs use "none" as the algorithm.
const header = {
alg: 'none',
type: 'JWT'
};

const project = projectId || 'demo-project';
const iat = token.iat || 0;
const sub = token.sub || token.user_id;
if (!sub) {
throw new Error("mockUserToken must contain 'sub' or 'user_id' field!");
}

const payload: FirebaseIdToken = {
// Set all required fields to decent defaults
iss: `https://securetoken.google.com/${project}`,
aud: project,
iat,
exp: iat + 3600,
auth_time: iat,
sub,
user_id: sub,
firebase: {
sign_in_provider: 'custom',
identities: {}
},

// Override with user options
...token
};

// Unsecured JWTs use the empty string as a signature.
const signature = '';
return [
base64.encodeString(JSON.stringify(header), /*webSafe=*/ false),
base64.encodeString(JSON.stringify(payload), /*webSafe=*/ false),
signature
].join('.');
}

0 comments on commit ac4ad08

Please sign in to comment.