Skip to content
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

Add mockUserToken support for database emulator. #4792

Merged
merged 22 commits into from
Apr 29, 2021
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4f7f840
Add mockUserToken support for database emulator.
yuchenshi Apr 15, 2021
21374c2
Add compact API.
yuchenshi Apr 15, 2021
5fd82c7
Add generated API file.
yuchenshi Apr 15, 2021
4d8b954
Change default project ID to demo-project.
yuchenshi Apr 20, 2021
e5dae20
Merge remote-tracking branch 'origin/master' into ys/mockUserToken-da…
yuchenshi Apr 22, 2021
6bdd4e8
Merge remote-tracking branch 'origin/master' into ys/mockUserToken-da…
yuchenshi Apr 22, 2021
9f0b203
Fix db.useEmulator
schmidt-sebastian Apr 23, 2021
a829398
Create sweet-monkeys-warn.md
schmidt-sebastian Apr 23, 2021
efe6d17
Update packages/database/src/exp/Database.ts
yuchenshi Apr 23, 2021
f0b11f0
Update packages/util/test/emulator.test.ts
yuchenshi Apr 23, 2021
82cd88e
Merge remote-tracking branch 'origin/mrschmidt/emulator' into ys/mock…
yuchenshi Apr 24, 2021
ff24362
Fix sub field name.
yuchenshi Apr 24, 2021
05c383e
Remove optional in jsdocs.
yuchenshi Apr 24, 2021
9d99437
Create loud-feet-jump.md
yuchenshi Apr 24, 2021
f3aebe0
Update loud-feet-jump.md
yuchenshi Apr 24, 2021
a684219
Make sub/user_id required in typing.
yuchenshi Apr 27, 2021
6238b8b
Update error messages to contain mockUserToken.
yuchenshi Apr 27, 2021
2059715
Merge branch 'master' into ys/mockUserToken-database
yuchenshi Apr 27, 2021
3a1cd98
Add API changes in md.
yuchenshi Apr 27, 2021
ea53f0c
Update error message for uid field.
yuchenshi Apr 28, 2021
c0b51f8
Update loud-feet-jump.md
yuchenshi Apr 28, 2021
dc72577
Change custom claim typing to unknown.
yuchenshi Apr 29, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion common/api-review/database.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
```ts

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

// @public (undocumented)
export function child(parent: Reference, path: string): Reference;
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?: Partial<FirebaseIdToken>;
}): void;


```
13 changes: 10 additions & 3 deletions packages/database/src/api/Database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

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

import {
FirebaseDatabase as ExpDatabase,
Expand Down Expand Up @@ -58,9 +58,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 - Optional: The mock token to use (for unit testing Security Rules)
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved
*/
useEmulator(host: string, port: number): void {
useDatabaseEmulator(this._delegate, host, port);
useEmulator(
host: string,
port: number,
options: {
mockUserToken?: Partial<FirebaseIdToken>;
} = {}
): void {
useDatabaseEmulator(this._delegate, host, port, options);
}

/**
Expand Down
13 changes: 8 additions & 5 deletions packages/database/src/core/AuthTokenProvider.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ 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,
FirebaseIdToken
} from '@firebase/util';

import {
AuthTokenProvider,
EmulatorAdminTokenProvider,
EmulatorTokenProvider,
FirebaseAuthTokenProvider
} from '../core/AuthTokenProvider';
import { Repo, repoInterrupt, repoResume, repoStart } from '../core/Repo';
Expand Down Expand Up @@ -74,7 +78,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 @@ -86,8 +91,8 @@ function repoManagerApplyEmulatorSettings(
repo.repoInfo_.includeNamespaceInQueryParams
);

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

Expand Down Expand Up @@ -135,7 +140,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 @@ -286,11 +291,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 - Optional: The mock token to use (for unit testing Security Rules)
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved
*/
export function useDatabaseEmulator(
db: FirebaseDatabase,
host: string,
port: number
port: number,
options: {
mockUserToken?: Partial<FirebaseIdToken>;
} = {}
): void {
db = getModularInstance(db);
db._checkNotDeleted('useEmulator');
Expand All @@ -299,8 +308,26 @@ export function useDatabaseEmulator(
'Cannot call useEmulator() after instance has already been initialized.'
);
}

const repo = db._repo;
let tokenProvider: EmulatorTokenProvider | undefined = undefined;
if (repo.repoInfo_.nodeAdmin) {
if (options.mockUserToken) {
fatal(
'mockUserToken is not supported on the Admin SDK. For client access with mock users, please use the "firebase" package instead of "firebase-admin".'
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved
);
}
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._repo, host, port);
repoManagerApplyEmulatorSettings(repo, host, port, tokenProvider);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/util/index.node.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
140 changes: 140 additions & 0 deletions packages/util/src/emulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* @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';

export 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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[claim: string]: any;
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved

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

export function createMockUserToken(
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved
token: Partial<FirebaseIdToken>,
projectId?: string
): string {
if (token.uid) {
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved
throw new Error(
'Invalid Firebase token field "uid". Did you mean "sub" (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 uid = token.uid || token.user_id;
if (!uid) {
throw new Error("Auth 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: uid,
user_id: uid,
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('.');
}
58 changes: 58 additions & 0 deletions packages/util/test/emulator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2017 Google LLC
yuchenshi marked this conversation as resolved.
Show resolved Hide resolved
*
* 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 { expect } from 'chai';
import { base64 } from '../src/crypt';
import { createMockUserToken, FirebaseIdToken } from '../src/emulator';

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

describe('createMockUserToken()', () => {
it('creates a well-formed JWT', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migrated from an old rules unit testing library test

const projectId = 'my-project';
const options = { user_id: 'alice' };

const token = createMockUserToken(options, projectId);
const claims = JSON.parse(
base64.decodeString(token.split('.')[1], /*webSafe=*/ false)
);
// We add an 'iat' field.
expect(claims).to.deep.equal({
iss: 'https://securetoken.google.com/' + projectId,
aud: projectId,
iat: 0,
exp: 3600,
auth_time: 0,
sub: 'alice',
user_id: 'alice',
firebase: {
sign_in_provider: 'custom',
identities: {}
}
});
});

it('rejects "uid" field with error', () => {
const options = { uid: 'alice' };

expect(() =>
createMockUserToken((options as unknown) as Partial<FirebaseIdToken>)
).to.throw(
'Invalid Firebase token field "uid". Did you mean "sub" (for Firebase Auth User ID)?'
);
});
});